Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/composables/node/useNodePricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,74 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
: `$${minCost.toFixed(2)}-$${maxCost.toFixed(2)}/Run`
}

// ---- constants ----
const SORA_SIZES = {
BASIC: new Set(['720x1280', '1280x720']),
PRO: new Set(['1024x1792', '1792x1024'])
}
const ALL_SIZES = new Set([...SORA_SIZES.BASIC, ...SORA_SIZES.PRO])

// ---- sora-2 pricing helpers ----
function validateSora2Selection(
modelRaw: string,
duration: number,
sizeRaw: string
): string | undefined {
const model = modelRaw?.toLowerCase() ?? ''
const size = sizeRaw?.toLowerCase() ?? ''

if (!duration || Number.isNaN(duration)) return 'Set duration (4s / 8s / 12s)'
if (!size) return 'Set size (720x1280, 1280x720, 1024x1792, 1792x1024)'
if (!ALL_SIZES.has(size))
return 'Invalid size. Must be 720x1280, 1280x720, 1024x1792, or 1792x1024.'

if (model.includes('sora-2-pro')) return undefined

if (model.includes('sora-2') && !SORA_SIZES.BASIC.has(size))
return 'sora-2 supports only 720x1280 or 1280x720'

if (!model.includes('sora-2')) return 'Unsupported model'

return undefined
}

function perSecForSora2(modelRaw: string, sizeRaw: string): number {
const model = modelRaw?.toLowerCase() ?? ''
const size = sizeRaw?.toLowerCase() ?? ''

if (model.includes('sora-2-pro')) {
return SORA_SIZES.PRO.has(size) ? 0.5 : 0.3
}
if (model.includes('sora-2')) return 0.1

return SORA_SIZES.PRO.has(size) ? 0.5 : 0.1
}

function formatRunPrice(perSec: number, duration: number) {
return `$${(perSec * duration).toFixed(2)}/Run`
}

// ---- pricing calculator ----
const sora2PricingCalculator: PricingFunction = (node: LGraphNode): string => {
const getWidgetValue = (name: string) =>
String(node.widgets?.find((w) => w.name === name)?.value ?? '')

const model = getWidgetValue('model')
const size = getWidgetValue('size')
const duration = Number(
node.widgets?.find((w) => ['duration', 'duration_s'].includes(w.name))
?.value
)

if (!model || !size || !duration) return 'Set model, duration & size'

const validationError = validateSora2Selection(model, duration, size)
if (validationError) return validationError

const perSec = perSecForSora2(model, size)
return formatRunPrice(perSec, duration)
}

/**
* Static pricing data for API nodes, now supporting both strings and functions
*/
Expand All @@ -195,6 +263,9 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
FluxProKontextMaxNode: {
displayPrice: '$0.08/Run'
},
OpenAIVideoSora2: {
displayPrice: sora2PricingCalculator
},
IdeogramV1: {
displayPrice: (node: LGraphNode): string => {
const numImagesWidget = node.widgets?.find(
Expand Down Expand Up @@ -1589,6 +1660,7 @@ export const useNodePricing = () => {
MinimaxHailuoVideoNode: ['resolution', 'duration'],
OpenAIDalle3: ['size', 'quality'],
OpenAIDalle2: ['size', 'n'],
OpenAIVideoSora2: ['model', 'size', 'duration'],
OpenAIGPTImage1: ['quality', 'n'],
IdeogramV1: ['num_images', 'turbo'],
IdeogramV2: ['num_images', 'turbo'],
Expand Down
110 changes: 110 additions & 0 deletions tests-ui/tests/composables/node/useNodePricing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,117 @@
expect(price).toBe('$0.04-0.12/Run (varies with size & quality)')
})
})
// ============================== OpenAIVideoSora2 ==============================
describe('dynamic pricing - OpenAIVideoSora2', () => {
it('should require model, duration & size when widgets are missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIVideoSora2', [])
expect(getNodeDisplayPrice(node)).toBe('Set model, duration & size')
})

it('should require duration when duration is invalid or zero', () => {
const { getNodeDisplayPrice } = useNodePricing()
const nodeNaN = createMockNode('OpenAIVideoSora2', [
{ name: 'model', value: 'sora-2-pro' },
{ name: 'duration', value: 'oops' },
{ name: 'size', value: '720x1280' }
])
expect(getNodeDisplayPrice(nodeNaN)).toBe('Set duration (4/8/12)')

const nodeZero = createMockNode('OpenAIVideoSora2', [
{ name: 'model', value: 'sora-2-pro' },
{ name: 'duration', value: 0 },
{ name: 'size', value: '720x1280' }
])
expect(getNodeDisplayPrice(nodeZero)).toBe('Set duration (4/8/12)')
})

it('should require size when size is missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIVideoSora2', [
{ name: 'model', value: 'sora-2-pro' },
{ name: 'duration', value: 8 }
])
expect(getNodeDisplayPrice(node)).toBe(
'Set size (720x1280, 1280x720, 1024x1792, 1792x1024)'
)
})

it('should compute pricing for sora-2-pro with 1024x1792', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIVideoSora2', [
{ name: 'model', value: 'sora-2-pro' },
{ name: 'duration', value: 8 },
{ name: 'size', value: '1024x1792' }
])
expect(getNodeDisplayPrice(node)).toBe('$4.00/Run') // 0.5 * 8
})

it('should compute pricing for sora-2-pro with 720x1280', () => {
const { getNodeDisplayPrice } = useNodePricing()

Check failure on line 319 in tests-ui/tests/composables/node/useNodePricing.test.ts

View workflow job for this annotation

GitHub Actions / test

tests-ui/tests/composables/node/useNodePricing.test.ts > useNodePricing > dynamic pricing - OpenAIVideoSora2 > should require duration when duration is invalid or zero

AssertionError: expected 'Set model, duration & size' to be 'Set duration (4/8/12)' // Object.is equality Expected: "Set duration (4/8/12)" Received: "Set model, duration & size" ❯ tests-ui/tests/composables/node/useNodePricing.test.ts:319:44

Check failure on line 319 in tests-ui/tests/composables/node/useNodePricing.test.ts

View workflow job for this annotation

GitHub Actions / test

tests-ui/tests/composables/node/useNodePricing.test.ts > useNodePricing > dynamic pricing - OpenAIVideoSora2 > should require duration when duration is invalid or zero

AssertionError: expected 'Set model, duration & size' to be 'Set duration (4/8/12)' // Object.is equality Expected: "Set duration (4/8/12)" Received: "Set model, duration & size" ❯ tests-ui/tests/composables/node/useNodePricing.test.ts:319:44

Check failure on line 319 in tests-ui/tests/composables/node/useNodePricing.test.ts

View workflow job for this annotation

GitHub Actions / test

tests-ui/tests/composables/node/useNodePricing.test.ts > useNodePricing > dynamic pricing - OpenAIVideoSora2 > should require duration when duration is invalid or zero

AssertionError: expected 'Set model, duration & size' to be 'Set duration (4/8/12)' // Object.is equality Expected: "Set duration (4/8/12)" Received: "Set model, duration & size" ❯ tests-ui/tests/composables/node/useNodePricing.test.ts:319:44
const node = createMockNode('OpenAIVideoSora2', [
{ name: 'model', value: 'sora-2-pro' },
{ name: 'duration', value: 12 },
{ name: 'size', value: '720x1280' }
])
expect(getNodeDisplayPrice(node)).toBe('$3.60/Run') // 0.3 * 12
})

it('should reject unsupported size for sora-2-pro', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIVideoSora2', [
{ name: 'model', value: 'sora-2-pro' },
{ name: 'duration', value: 8 },
{ name: 'size', value: '640x640' }
])
expect(getNodeDisplayPrice(node)).toBe(

Check failure on line 335 in tests-ui/tests/composables/node/useNodePricing.test.ts

View workflow job for this annotation

GitHub Actions / test

tests-ui/tests/composables/node/useNodePricing.test.ts > useNodePricing > dynamic pricing - OpenAIVideoSora2 > should require size when size is missing

AssertionError: expected 'Set model, duration & size' to be 'Set size (720x1280, 1280x720, 1024x17…' // Object.is equality Expected: "Set size (720x1280, 1280x720, 1024x1792, 1792x1024)" Received: "Set model, duration & size" ❯ tests-ui/tests/composables/node/useNodePricing.test.ts:335:41

Check failure on line 335 in tests-ui/tests/composables/node/useNodePricing.test.ts

View workflow job for this annotation

GitHub Actions / test

tests-ui/tests/composables/node/useNodePricing.test.ts > useNodePricing > dynamic pricing - OpenAIVideoSora2 > should require size when size is missing

AssertionError: expected 'Set model, duration & size' to be 'Set size (720x1280, 1280x720, 1024x17…' // Object.is equality Expected: "Set size (720x1280, 1280x720, 1024x1792, 1792x1024)" Received: "Set model, duration & size" ❯ tests-ui/tests/composables/node/useNodePricing.test.ts:335:41

Check failure on line 335 in tests-ui/tests/composables/node/useNodePricing.test.ts

View workflow job for this annotation

GitHub Actions / test

tests-ui/tests/composables/node/useNodePricing.test.ts > useNodePricing > dynamic pricing - OpenAIVideoSora2 > should require size when size is missing

AssertionError: expected 'Set model, duration & size' to be 'Set size (720x1280, 1280x720, 1024x17…' // Object.is equality Expected: "Set size (720x1280, 1280x720, 1024x1792, 1792x1024)" Received: "Set model, duration & size" ❯ tests-ui/tests/composables/node/useNodePricing.test.ts:335:41
'Size must be 720x1280, 1280x720, 1024x1792, or 1792x1024'
)
})

it('should compute pricing for sora-2 (720x1280 only)', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIVideoSora2', [
{ name: 'model', value: 'sora-2' },
{ name: 'duration', value: 10 },
{ name: 'size', value: '720x1280' }
])
expect(getNodeDisplayPrice(node)).toBe('$1.00/Run') // 0.1 * 10
})

it('should reject non-720 sizes for sora-2', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIVideoSora2', [
{ name: 'model', value: 'sora-2' },
{ name: 'duration', value: 8 },
{ name: 'size', value: '1024x1792' }
])
expect(getNodeDisplayPrice(node)).toBe(
'sora-2 supports only 720x1280 or 1280x720'
)
})
it('should accept duration_s alias for duration', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIVideoSora2', [
{ name: 'model', value: 'sora-2-pro' },
{ name: 'duration_s', value: 4 },
{ name: 'size', value: '1792x1024' }
])

Check failure on line 367 in tests-ui/tests/composables/node/useNodePricing.test.ts

View workflow job for this annotation

GitHub Actions / test

tests-ui/tests/composables/node/useNodePricing.test.ts > useNodePricing > dynamic pricing - OpenAIVideoSora2 > should reject unsupported size for sora-2-pro

AssertionError: expected 'Invalid size. Must be 720x1280, 1280x…' to be 'Size must be 720x1280, 1280x720, 1024…' // Object.is equality Expected: "Size must be 720x1280, 1280x720, 1024x1792, or 1792x1024" Received: "Invalid size. Must be 720x1280, 1280x720, 1024x1792, or 1792x1024." ❯ tests-ui/tests/composables/node/useNodePricing.test.ts:367:41

Check failure on line 367 in tests-ui/tests/composables/node/useNodePricing.test.ts

View workflow job for this annotation

GitHub Actions / test

tests-ui/tests/composables/node/useNodePricing.test.ts > useNodePricing > dynamic pricing - OpenAIVideoSora2 > should reject unsupported size for sora-2-pro

AssertionError: expected 'Invalid size. Must be 720x1280, 1280x…' to be 'Size must be 720x1280, 1280x720, 1024…' // Object.is equality Expected: "Size must be 720x1280, 1280x720, 1024x1792, or 1792x1024" Received: "Invalid size. Must be 720x1280, 1280x720, 1024x1792, or 1792x1024." ❯ tests-ui/tests/composables/node/useNodePricing.test.ts:367:41

Check failure on line 367 in tests-ui/tests/composables/node/useNodePricing.test.ts

View workflow job for this annotation

GitHub Actions / test

tests-ui/tests/composables/node/useNodePricing.test.ts > useNodePricing > dynamic pricing - OpenAIVideoSora2 > should reject unsupported size for sora-2-pro

AssertionError: expected 'Invalid size. Must be 720x1280, 1280x…' to be 'Size must be 720x1280, 1280x720, 1024…' // Object.is equality Expected: "Size must be 720x1280, 1280x720, 1024x1792, or 1792x1024" Received: "Invalid size. Must be 720x1280, 1280x720, 1024x1792, or 1792x1024." ❯ tests-ui/tests/composables/node/useNodePricing.test.ts:367:41
expect(getNodeDisplayPrice(node)).toBe('$2.00/Run') // 0.5 * 4
})

it('should be case-insensitive for model and size', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIVideoSora2', [
{ name: 'model', value: 'SoRa-2-PrO' },
{ name: 'duration', value: 12 },
{ name: 'size', value: '1280x720' }
])
expect(getNodeDisplayPrice(node)).toBe('$3.60/Run') // 0.3 * 12
})
})

// ============================== MinimaxHailuoVideoNode ==============================
describe('dynamic pricing - MinimaxHailuoVideoNode', () => {
it('should return $0.28 for 6s duration and 768P resolution', () => {
const { getNodeDisplayPrice } = useNodePricing()
Expand Down
Loading