Browse Source

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
handoff-20260429-1057
shrimbly 6 months ago
parent
commit
18c3bf29e2
  1. 12
      src/components/CostDialog.tsx
  2. 211
      src/utils/costCalculator.ts

12
src/components/CostDialog.tsx

@ -61,17 +61,23 @@ export function CostDialog({ predictedCost, incurredCost, onClose }: CostDialogP
{predictedCost.breakdown.map((item, idx) => (
<div key={idx} className="flex justify-between text-xs">
<span className="text-neutral-500">
{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)"}
</span>
<span className="text-neutral-400">
{formatCost(item.subtotal)}
{item.subtotal !== null ? formatCost(item.subtotal) : "—"}
</span>
</div>
))}
</div>
)}
{predictedCost.unknownPricingCount > 0 && (
<p className="text-xs text-amber-500 mt-2">
{predictedCost.unknownPricingCount} model{predictedCost.unknownPricingCount > 1 ? "s" : ""} without pricing
</p>
)}
{predictedCost.nodeCount === 0 && (
<p className="text-xs text-neutral-500 mt-2">
No generation nodes in workflow

211
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<string, { model: ModelType; resolution: Resolution; count: number; unitCost: number }> = 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<string, ModelPricing>
): PredictedCostResult {
// Group by provider + modelId for breakdown
const breakdown: Map<string, CostBreakdownItem> = 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 modelName = model === "nano-banana" ? "Nano Banana" : "Nano Banana Pro";
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 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,
};
}

Loading…
Cancel
Save