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. 215
      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) => ( {predictedCost.breakdown.map((item, idx) => (
<div key={idx} className="flex justify-between text-xs"> <div key={idx} className="flex justify-between text-xs">
<span className="text-neutral-500"> <span className="text-neutral-500">
{item.count}x {item.model === "nano-banana" ? "Nano Banana" : "Nano Banana Pro"} {item.count}x {item.modelName}
{item.model === "nano-banana-pro" && ` (${item.resolution})`} {item.unitCost === null && " (no pricing)"}
</span> </span>
<span className="text-neutral-400"> <span className="text-neutral-400">
{formatCost(item.subtotal)} {item.subtotal !== null ? formatCost(item.subtotal) : "—"}
</span> </span>
</div> </div>
))} ))}
</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 && ( {predictedCost.nodeCount === 0 && (
<p className="text-xs text-neutral-500 mt-2"> <p className="text-xs text-neutral-500 mt-2">
No generation nodes in workflow No generation nodes in workflow

215
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) // Pricing in USD per image (Gemini API)
export const PRICING = { export const PRICING = {
@ -22,40 +22,189 @@ export function calculateGenerationCost(model: ModelType, resolution: Resolution
return PRICING["nano-banana-pro"][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 { export interface CostBreakdownItem {
model: ModelType; provider: ProviderType;
resolution: Resolution; modelId: string;
modelName: string;
count: number; count: number;
unitCost: number; unitCost: number | null; // null means pricing unavailable
subtotal: number; unit: string; // "image", "video", "second", etc.
subtotal: number | null; // null if unitCost is null
} }
/**
* Result of predicted cost calculation
*/
export interface PredictedCostResult { export interface PredictedCostResult {
totalCost: number; totalCost: number; // Only includes known pricing
breakdown: CostBreakdownItem[]; breakdown: CostBreakdownItem[];
nodeCount: number; 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 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) => { nodes.forEach((node) => {
// Handle nanoBanana (image generation) nodes
if (node.type === "nanoBanana") { if (node.type === "nanoBanana") {
const data = node.data as NanoBananaNodeData; const data = node.data as NanoBananaNodeData;
const model = data.model;
const resolution = model === "nano-banana" ? "1K" : data.resolution; // Determine provider and model info
const unitCost = calculateGenerationCost(model, resolution); let provider: ProviderType;
const key = `${model}-${resolution}`; let modelId: string;
let modelName: string;
const existing = breakdown.get(key);
if (existing) { if (data.selectedModel) {
existing.count++; // New multi-provider model selection
provider = data.selectedModel.provider;
modelId = data.selectedModel.modelId;
modelName = data.selectedModel.displayName;
} else { } 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 // 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) { if (data.isConfigured && data.targetCount > 0) {
const model = data.generateSettings.model; const model = data.generateSettings.model;
const resolution = model === "nano-banana" ? "1K" : data.generateSettings.resolution; const resolution = model === "nano-banana" ? "1K" : data.generateSettings.resolution;
const unitCost = calculateGenerationCost(model, resolution); const modelName = model === "nano-banana" ? "Nano Banana" : "Nano Banana Pro";
const key = `splitGrid-${model}-${resolution}`;
const pricing = getPricing("gemini", model, resolution);
const count = data.targetCount; const unitCost = pricing?.unitCost ?? null;
const existing = breakdown.get(key); const unit = pricing?.unit ?? "image";
if (existing) {
existing.count += count; addToBreakdown("gemini", model, modelName, unit, unitCost, data.targetCount);
} else {
breakdown.set(key, { model, resolution, count, unitCost });
}
nodeCount += count;
} }
} }
}); });
const breakdownArray = Array.from(breakdown.values()).map((item) => ({ const breakdownArray = Array.from(breakdown.values());
...item, const totalCost = breakdownArray.reduce(
subtotal: item.count * item.unitCost, (sum, item) => sum + (item.subtotal ?? 0),
})); 0
);
const totalCost = breakdownArray.reduce((sum, item) => sum + item.subtotal, 0);
return { return {
totalCost, totalCost,
breakdown: breakdownArray, breakdown: breakdownArray,
nodeCount, nodeCount,
unknownPricingCount,
}; };
} }

Loading…
Cancel
Save