Skip to content

Commit 191b457

Browse files
[fix] Add dynamic pricing for API nodes with quantity parameters (#4362)
1 parent 0c4339f commit 191b457

File tree

2 files changed

+230
-31
lines changed

2 files changed

+230
-31
lines changed

src/composables/node/useNodePricing.ts

Lines changed: 98 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -275,30 +275,33 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
275275
const modelWidget = node.widgets?.find(
276276
(w) => w.name === 'model_name'
277277
) as IComboWidget
278+
const nWidget = node.widgets?.find(
279+
(w) => w.name === 'n'
280+
) as IComboWidget
278281

279282
if (!modelWidget)
280-
return '$0.0035-0.028/Run (varies with modality & model)'
283+
return '$0.0035-0.028 x n/Run (varies with modality & model)'
281284

282285
const model = String(modelWidget.value)
286+
const n = Number(nWidget?.value) || 1
287+
let basePrice = 0.014 // default
283288

284289
if (modality.includes('text to image')) {
285-
if (model.includes('kling-v1')) {
286-
return '$0.0035/Run'
287-
} else if (
288-
model.includes('kling-v1-5') ||
289-
model.includes('kling-v2')
290-
) {
291-
return '$0.014/Run'
290+
if (model.includes('kling-v1-5') || model.includes('kling-v2')) {
291+
basePrice = 0.014
292+
} else if (model.includes('kling-v1')) {
293+
basePrice = 0.0035
292294
}
293295
} else if (modality.includes('image to image')) {
294-
if (model.includes('kling-v1')) {
295-
return '$0.0035/Run'
296-
} else if (model.includes('kling-v1-5')) {
297-
return '$0.028/Run'
296+
if (model.includes('kling-v1-5')) {
297+
basePrice = 0.028
298+
} else if (model.includes('kling-v1')) {
299+
basePrice = 0.0035
298300
}
299301
}
300302

301-
return '$0.014/Run'
303+
const totalCost = (basePrice * n).toFixed(4)
304+
return `$${totalCost}/Run`
302305
}
303306
},
304307
KlingLipSyncAudioToVideoNode: {
@@ -523,19 +526,26 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
523526
const sizeWidget = node.widgets?.find(
524527
(w) => w.name === 'size'
525528
) as IComboWidget
529+
const nWidget = node.widgets?.find(
530+
(w) => w.name === 'n'
531+
) as IComboWidget
526532

527-
if (!sizeWidget) return '$0.016-0.02/Run (varies with size)'
533+
if (!sizeWidget) return '$0.016-0.02 x n/Run (varies with size & n)'
528534

529535
const size = String(sizeWidget.value)
536+
const n = Number(nWidget?.value) || 1
537+
let basePrice = 0.02 // default
538+
530539
if (size.includes('1024x1024')) {
531-
return '$0.02/Run'
540+
basePrice = 0.02
532541
} else if (size.includes('512x512')) {
533-
return '$0.018/Run'
542+
basePrice = 0.018
534543
} else if (size.includes('256x256')) {
535-
return '$0.016/Run'
544+
basePrice = 0.016
536545
}
537546

538-
return '$0.02/Run'
547+
const totalCost = (basePrice * n).toFixed(3)
548+
return `$${totalCost}/Run`
539549
}
540550
},
541551
OpenAIDalle3: {
@@ -570,19 +580,30 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
570580
const qualityWidget = node.widgets?.find(
571581
(w) => w.name === 'quality'
572582
) as IComboWidget
583+
const nWidget = node.widgets?.find(
584+
(w) => w.name === 'n'
585+
) as IComboWidget
573586

574-
if (!qualityWidget) return '$0.011-0.30/Run (varies with quality)'
587+
if (!qualityWidget)
588+
return '$0.011-0.30 x n/Run (varies with quality & n)'
575589

576590
const quality = String(qualityWidget.value)
591+
const n = Number(nWidget?.value) || 1
592+
let basePriceRange = '$0.046-0.07' // default medium
593+
577594
if (quality.includes('high')) {
578-
return '$0.167-0.30/Run'
595+
basePriceRange = '$0.167-0.30'
579596
} else if (quality.includes('medium')) {
580-
return '$0.046-0.07/Run'
597+
basePriceRange = '$0.046-0.07'
581598
} else if (quality.includes('low')) {
582-
return '$0.011-0.02/Run'
599+
basePriceRange = '$0.011-0.02'
583600
}
584601

585-
return '$0.046-0.07/Run'
602+
if (n === 1) {
603+
return `${basePriceRange}/Run`
604+
} else {
605+
return `${basePriceRange} x ${n}/Run`
606+
}
586607
}
587608
},
588609
PikaImageToVideoNode2_2: {
@@ -717,6 +738,42 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
717738
RecraftCrispUpscaleNode: {
718739
displayPrice: '$0.004/Run'
719740
},
741+
RecraftGenerateColorFromImageNode: {
742+
displayPrice: (node: LGraphNode): string => {
743+
const nWidget = node.widgets?.find(
744+
(w) => w.name === 'n'
745+
) as IComboWidget
746+
if (!nWidget) return '$0.04 x n/Run'
747+
748+
const n = Number(nWidget.value) || 1
749+
const cost = (0.04 * n).toFixed(2)
750+
return `$${cost}/Run`
751+
}
752+
},
753+
RecraftGenerateImageNode: {
754+
displayPrice: (node: LGraphNode): string => {
755+
const nWidget = node.widgets?.find(
756+
(w) => w.name === 'n'
757+
) as IComboWidget
758+
if (!nWidget) return '$0.04 x n/Run'
759+
760+
const n = Number(nWidget.value) || 1
761+
const cost = (0.04 * n).toFixed(2)
762+
return `$${cost}/Run`
763+
}
764+
},
765+
RecraftGenerateVectorImageNode: {
766+
displayPrice: (node: LGraphNode): string => {
767+
const nWidget = node.widgets?.find(
768+
(w) => w.name === 'n'
769+
) as IComboWidget
770+
if (!nWidget) return '$0.08 x n/Run'
771+
772+
const n = Number(nWidget.value) || 1
773+
const cost = (0.08 * n).toFixed(2)
774+
return `$${cost}/Run`
775+
}
776+
},
720777
RecraftImageInpaintingNode: {
721778
displayPrice: (node: LGraphNode): string => {
722779
const nWidget = node.widgets?.find(
@@ -772,7 +829,16 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
772829
}
773830
},
774831
RecraftVectorizeImageNode: {
775-
displayPrice: '$0.01/Run'
832+
displayPrice: (node: LGraphNode): string => {
833+
const nWidget = node.widgets?.find(
834+
(w) => w.name === 'n'
835+
) as IComboWidget
836+
if (!nWidget) return '$0.01 x n/Run'
837+
838+
const n = Number(nWidget.value) || 1
839+
const cost = (0.01 * n).toFixed(2)
840+
return `$${cost}/Run`
841+
}
776842
},
777843
StabilityStableImageSD_3_5Node: {
778844
displayPrice: (node: LGraphNode): string => {
@@ -915,13 +981,13 @@ export const useNodePricing = () => {
915981
const widgetMap: Record<string, string[]> = {
916982
KlingTextToVideoNode: ['mode', 'model_name', 'duration'],
917983
KlingImage2VideoNode: ['mode', 'model_name', 'duration'],
918-
KlingImageGenerationNode: ['modality', 'model_name'],
984+
KlingImageGenerationNode: ['modality', 'model_name', 'n'],
919985
KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'],
920986
KlingSingleImageVideoEffectNode: ['effect_scene'],
921987
KlingStartEndFrameNode: ['mode', 'model_name', 'duration'],
922988
OpenAIDalle3: ['size', 'quality'],
923-
OpenAIDalle2: ['size'],
924-
OpenAIGPTImage1: ['quality'],
989+
OpenAIDalle2: ['size', 'n'],
990+
OpenAIGPTImage1: ['quality', 'n'],
925991
IdeogramV1: ['num_images'],
926992
IdeogramV2: ['num_images'],
927993
IdeogramV3: ['rendering_speed', 'num_images'],
@@ -945,7 +1011,11 @@ export const useNodePricing = () => {
9451011
RecraftTextToImageNode: ['n'],
9461012
RecraftImageToImageNode: ['n'],
9471013
RecraftImageInpaintingNode: ['n'],
948-
RecraftTextToVectorNode: ['n']
1014+
RecraftTextToVectorNode: ['n'],
1015+
RecraftVectorizeImageNode: ['n'],
1016+
RecraftGenerateColorFromImageNode: ['n'],
1017+
RecraftGenerateImageNode: ['n'],
1018+
RecraftGenerateVectorImageNode: ['n']
9491019
}
9501020
return widgetMap[nodeType] || []
9511021
}

tests-ui/tests/composables/node/useNodePricing.test.ts

Lines changed: 132 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ describe('useNodePricing', () => {
227227
])
228228

229229
const price = getNodeDisplayPrice(node)
230-
expect(price).toBe('$0.02/Run')
230+
expect(price).toBe('$0.020/Run')
231231
})
232232

233233
it('should return $0.018 for 512x512 size', () => {
@@ -255,7 +255,7 @@ describe('useNodePricing', () => {
255255
const node = createMockNode('OpenAIDalle2', [])
256256

257257
const price = getNodeDisplayPrice(node)
258-
expect(price).toBe('$0.016-0.02/Run (varies with size)')
258+
expect(price).toBe('$0.016-0.02 x n/Run (varies with size & n)')
259259
})
260260
})
261261

@@ -295,7 +295,7 @@ describe('useNodePricing', () => {
295295
const node = createMockNode('OpenAIGPTImage1', [])
296296

297297
const price = getNodeDisplayPrice(node)
298-
expect(price).toBe('$0.011-0.30/Run (varies with quality)')
298+
expect(price).toBe('$0.011-0.30 x n/Run (varies with quality & n)')
299299
})
300300
})
301301

@@ -894,4 +894,133 @@ describe('useNodePricing', () => {
894894
})
895895
})
896896
})
897+
898+
describe('OpenAI nodes dynamic pricing with n parameter', () => {
899+
it('should calculate dynamic pricing for OpenAIDalle2 based on size and n', () => {
900+
const { getNodeDisplayPrice } = useNodePricing()
901+
const node = createMockNode('OpenAIDalle2', [
902+
{ name: 'size', value: '1024x1024' },
903+
{ name: 'n', value: 3 }
904+
])
905+
906+
const price = getNodeDisplayPrice(node)
907+
expect(price).toBe('$0.060/Run') // 0.02 * 3
908+
})
909+
910+
it('should calculate dynamic pricing for OpenAIGPTImage1 based on quality and n', () => {
911+
const { getNodeDisplayPrice } = useNodePricing()
912+
const node = createMockNode('OpenAIGPTImage1', [
913+
{ name: 'quality', value: 'low' },
914+
{ name: 'n', value: 2 }
915+
])
916+
917+
const price = getNodeDisplayPrice(node)
918+
expect(price).toBe('$0.011-0.02 x 2/Run')
919+
})
920+
921+
it('should fall back to static display when n widget is missing for OpenAIDalle2', () => {
922+
const { getNodeDisplayPrice } = useNodePricing()
923+
const node = createMockNode('OpenAIDalle2', [
924+
{ name: 'size', value: '512x512' }
925+
])
926+
927+
const price = getNodeDisplayPrice(node)
928+
expect(price).toBe('$0.018/Run') // n defaults to 1
929+
})
930+
})
931+
932+
describe('KlingImageGenerationNode dynamic pricing with n parameter', () => {
933+
it('should calculate dynamic pricing for text-to-image with kling-v1', () => {
934+
const { getNodeDisplayPrice } = useNodePricing()
935+
const node = createMockNode('KlingImageGenerationNode', [
936+
{ name: 'model_name', value: 'kling-v1' },
937+
{ name: 'n', value: 4 }
938+
])
939+
940+
const price = getNodeDisplayPrice(node)
941+
expect(price).toBe('$0.0140/Run') // 0.0035 * 4
942+
})
943+
944+
it('should calculate dynamic pricing for text-to-image with kling-v1-5', () => {
945+
const { getNodeDisplayPrice } = useNodePricing()
946+
// Mock node without image input (text-to-image mode)
947+
const node = createMockNode('KlingImageGenerationNode', [
948+
{ name: 'model_name', value: 'kling-v1-5' },
949+
{ name: 'n', value: 2 }
950+
])
951+
952+
const price = getNodeDisplayPrice(node)
953+
expect(price).toBe('$0.0280/Run') // For kling-v1-5 text-to-image: 0.014 * 2
954+
})
955+
956+
it('should fall back to static display when model widget is missing', () => {
957+
const { getNodeDisplayPrice } = useNodePricing()
958+
const node = createMockNode('KlingImageGenerationNode', [])
959+
960+
const price = getNodeDisplayPrice(node)
961+
expect(price).toBe('$0.0035-0.028 x n/Run (varies with modality & model)')
962+
})
963+
})
964+
965+
describe('New Recraft nodes dynamic pricing', () => {
966+
it('should calculate dynamic pricing for RecraftGenerateImageNode', () => {
967+
const { getNodeDisplayPrice } = useNodePricing()
968+
const node = createMockNode('RecraftGenerateImageNode', [
969+
{ name: 'n', value: 3 }
970+
])
971+
972+
const price = getNodeDisplayPrice(node)
973+
expect(price).toBe('$0.12/Run') // 0.04 * 3
974+
})
975+
976+
it('should calculate dynamic pricing for RecraftVectorizeImageNode', () => {
977+
const { getNodeDisplayPrice } = useNodePricing()
978+
const node = createMockNode('RecraftVectorizeImageNode', [
979+
{ name: 'n', value: 5 }
980+
])
981+
982+
const price = getNodeDisplayPrice(node)
983+
expect(price).toBe('$0.05/Run') // 0.01 * 5
984+
})
985+
986+
it('should calculate dynamic pricing for RecraftGenerateVectorImageNode', () => {
987+
const { getNodeDisplayPrice } = useNodePricing()
988+
const node = createMockNode('RecraftGenerateVectorImageNode', [
989+
{ name: 'n', value: 2 }
990+
])
991+
992+
const price = getNodeDisplayPrice(node)
993+
expect(price).toBe('$0.16/Run') // 0.08 * 2
994+
})
995+
})
996+
997+
describe('Widget names for reactive updates', () => {
998+
it('should include n parameter for OpenAI nodes', () => {
999+
const { getRelevantWidgetNames } = useNodePricing()
1000+
1001+
expect(getRelevantWidgetNames('OpenAIDalle2')).toEqual(['size', 'n'])
1002+
expect(getRelevantWidgetNames('OpenAIGPTImage1')).toEqual([
1003+
'quality',
1004+
'n'
1005+
])
1006+
})
1007+
1008+
it('should include n parameter for Kling and new Recraft nodes', () => {
1009+
const { getRelevantWidgetNames } = useNodePricing()
1010+
1011+
expect(getRelevantWidgetNames('KlingImageGenerationNode')).toEqual([
1012+
'modality',
1013+
'model_name',
1014+
'n'
1015+
])
1016+
expect(getRelevantWidgetNames('RecraftVectorizeImageNode')).toEqual(['n'])
1017+
expect(getRelevantWidgetNames('RecraftGenerateImageNode')).toEqual(['n'])
1018+
expect(getRelevantWidgetNames('RecraftGenerateVectorImageNode')).toEqual([
1019+
'n'
1020+
])
1021+
expect(
1022+
getRelevantWidgetNames('RecraftGenerateColorFromImageNode')
1023+
).toEqual(['n'])
1024+
})
1025+
})
8971026
})

0 commit comments

Comments
 (0)