[feat] Add pricing for new API nodes (#4391)

This commit is contained in:
Christian Byrne
2025-07-21 20:02:22 -07:00
committed by GitHub
parent 1cd6a7f667
commit 61611fb0cb
2 changed files with 598 additions and 1 deletions

View File

@@ -1004,6 +1004,194 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return '$2.25/Run'
}
},
// Runway nodes - using actual node names from ComfyUI
RunwayTextToImageNode: {
displayPrice: '$0.08/Run'
},
RunwayImageToVideoNodeGen3a: {
displayPrice: (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
if (!durationWidget) return '$0.05/second'
const duration = Number(durationWidget.value) || 5
const cost = (0.05 * duration).toFixed(2)
return `$${cost}/Run`
}
},
RunwayImageToVideoNodeGen4: {
displayPrice: (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
if (!durationWidget) return '$0.05/second'
const duration = Number(durationWidget.value) || 5
const cost = (0.05 * duration).toFixed(2)
return `$${cost}/Run`
}
},
RunwayFirstLastFrameNode: {
displayPrice: (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
if (!durationWidget) return '$0.05/second'
const duration = Number(durationWidget.value) || 5
const cost = (0.05 * duration).toFixed(2)
return `$${cost}/Run`
}
},
// Rodin nodes - all have the same pricing structure
Rodin3D_Regular: {
displayPrice: '$0.4/Run'
},
Rodin3D_Detail: {
displayPrice: '$1.2/Run'
},
Rodin3D_Smooth: {
displayPrice: '$1.2/Run'
},
Rodin3D_Sketch: {
displayPrice: '$0.4/Run'
},
// Tripo nodes - using actual node names from ComfyUI
TripoTextToModelNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model' || w.name === 'model_version'
) as IComboWidget
const textureQualityWidget = node.widgets?.find(
(w) => w.name === 'texture_quality'
) as IComboWidget
if (!modelWidget)
return '$0.2-0.3/Run (varies with model & texture quality)'
const model = String(modelWidget.value)
const textureQuality = String(textureQualityWidget?.value || 'standard')
// V2.5 pricing
if (model.includes('v2.5') || model.includes('2.5')) {
return textureQuality.includes('detailed') ? '$0.3/Run' : '$0.2/Run'
}
// V2.0 pricing
else if (model.includes('v2.0') || model.includes('2.0')) {
return textureQuality.includes('detailed') ? '$0.3/Run' : '$0.2/Run'
}
// V1.4 or legacy pricing
else {
return '$0.2/Run'
}
}
},
TripoImageToModelNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model' || w.name === 'model_version'
) as IComboWidget
const textureQualityWidget = node.widgets?.find(
(w) => w.name === 'texture_quality'
) as IComboWidget
if (!modelWidget)
return '$0.3-0.4/Run (varies with model & texture quality)'
const model = String(modelWidget.value)
const textureQuality = String(textureQualityWidget?.value || 'standard')
// V2.5 and V2.0 have same pricing structure
if (
model.includes('v2.5') ||
model.includes('2.5') ||
model.includes('v2.0') ||
model.includes('2.0')
) {
return textureQuality.includes('detailed') ? '$0.4/Run' : '$0.3/Run'
}
// V1.4 or legacy pricing (image_to_model is always $0.3)
else {
return '$0.3/Run'
}
}
},
TripoRefineNode: {
displayPrice: '$0.3/Run'
},
TripoTextureNode: {
displayPrice: (node: LGraphNode): string => {
const textureQualityWidget = node.widgets?.find(
(w) => w.name === 'texture_quality'
) as IComboWidget
if (!textureQualityWidget) return '$0.1-0.2/Run (varies with quality)'
const textureQuality = String(textureQualityWidget.value)
return textureQuality.includes('detailed') ? '$0.2/Run' : '$0.1/Run'
}
},
// Google/Gemini nodes
GeminiNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model'
) as IComboWidget
if (!modelWidget) return 'Token-based'
const model = String(modelWidget.value)
// Google Veo video generation
if (model.includes('veo-2.0')) {
return '$0.5/second'
} else if (model.includes('gemini-2.5-pro-preview-05-06')) {
return '$0.0035/$0.0008 per 1K tokens'
} else if (model.includes('gemini-2.5-flash-preview-04-17')) {
return '$0.0015/$0.0004 per 1K tokens'
}
// For other Gemini models, show token-based pricing info
return 'Token-based'
}
},
// OpenAI nodes
OpenAIChatNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model'
) as IComboWidget
if (!modelWidget) return 'Token-based'
const model = String(modelWidget.value)
// Specific pricing for exposed models based on official pricing data (converted to per 1K tokens)
if (model.includes('o4-mini')) {
return '$0.0011/$0.0044 per 1K tokens'
} else if (model.includes('o1-pro')) {
return '$0.15/$0.60 per 1K tokens'
} else if (model.includes('o1')) {
return '$0.015/$0.06 per 1K tokens'
} else if (model.includes('o3-mini')) {
return '$0.0011/$0.0044 per 1K tokens'
} else if (model.includes('o3')) {
return '$0.01/$0.04 per 1K tokens'
} else if (model.includes('gpt-4o')) {
return '$0.0025/$0.01 per 1K tokens'
} else if (model.includes('gpt-4.1-nano')) {
return '$0.0001/$0.0004 per 1K tokens'
} else if (model.includes('gpt-4.1-mini')) {
return '$0.0004/$0.0016 per 1K tokens'
} else if (model.includes('gpt-4.1')) {
return '$0.002/$0.008 per 1K tokens'
}
return 'Token-based'
}
}
}
@@ -1075,7 +1263,19 @@ export const useNodePricing = () => {
RecraftGenerateVectorImageNode: ['n'],
MoonvalleyTxt2VideoNode: ['length'],
MoonvalleyImg2VideoNode: ['length'],
MoonvalleyVideo2VideoNode: ['length']
MoonvalleyVideo2VideoNode: ['length'],
// Runway nodes
RunwayImageToVideoNodeGen3a: ['duration'],
RunwayImageToVideoNodeGen4: ['duration'],
RunwayFirstLastFrameNode: ['duration'],
// Tripo nodes
TripoTextToModelNode: ['model', 'model_version', 'texture_quality'],
TripoImageToModelNode: ['model', 'model_version', 'texture_quality'],
TripoTextureNode: ['texture_quality'],
// Google/Gemini nodes
GeminiNode: ['model'],
// OpenAI nodes
OpenAIChatNode: ['model']
}
return widgetMap[nodeType] || []
}

View File

@@ -1022,5 +1022,402 @@ describe('useNodePricing', () => {
getRelevantWidgetNames('RecraftGenerateColorFromImageNode')
).toEqual(['n'])
})
it('should include relevant widget names for new nodes', () => {
const { getRelevantWidgetNames } = useNodePricing()
expect(getRelevantWidgetNames('RunwayImageToVideoNodeGen3a')).toEqual([
'duration'
])
expect(getRelevantWidgetNames('RunwayImageToVideoNodeGen4')).toEqual([
'duration'
])
expect(getRelevantWidgetNames('RunwayFirstLastFrameNode')).toEqual([
'duration'
])
expect(getRelevantWidgetNames('TripoTextToModelNode')).toEqual([
'model',
'model_version',
'texture_quality'
])
expect(getRelevantWidgetNames('TripoImageToModelNode')).toEqual([
'model',
'model_version',
'texture_quality'
])
})
})
describe('New API nodes pricing', () => {
describe('RunwayML nodes', () => {
it('should return static price for RunwayTextToImageNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('RunwayTextToImageNode')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.08/Run')
})
it('should calculate dynamic pricing for RunwayImageToVideoNodeGen3a', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('RunwayImageToVideoNodeGen3a', [
{ name: 'duration', value: 10 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.50/Run') // 0.05 * 10
})
it('should return fallback for RunwayImageToVideoNodeGen3a without duration', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('RunwayImageToVideoNodeGen3a', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.05/second')
})
})
describe('Rodin nodes', () => {
it('should return base price for Rodin3D_Regular', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Rodin3D_Regular')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.4/Run')
})
it('should return addon price for Rodin3D_Detail', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Rodin3D_Detail')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$1.2/Run')
})
it('should return addon price for Rodin3D_Smooth', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Rodin3D_Smooth')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$1.2/Run')
})
})
describe('Tripo nodes', () => {
it('should return v2.5 standard pricing for TripoTextToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v2.5-20250123' },
{ name: 'texture_quality', value: 'standard' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.2/Run')
})
it('should return v2.5 detailed pricing for TripoTextToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v2.5-20250123' },
{ name: 'texture_quality', value: 'detailed' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.3/Run')
})
it('should return v2.0 detailed pricing for TripoImageToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoImageToModelNode', [
{ name: 'model_version', value: 'v2.0-20240919' },
{ name: 'texture_quality', value: 'detailed' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.4/Run')
})
it('should return legacy pricing for TripoTextToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v1.4-legacy' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.2/Run')
})
it('should return static price for TripoRefineNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoRefineNode')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.3/Run')
})
it('should return fallback for TripoTextToModelNode without model', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.2-0.3/Run (varies with model & texture quality)')
})
it('should return texture-based pricing for TripoTextureNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const standardNode = createMockNode('TripoTextureNode', [
{ name: 'texture_quality', value: 'standard' }
])
const detailedNode = createMockNode('TripoTextureNode', [
{ name: 'texture_quality', value: 'detailed' }
])
expect(getNodeDisplayPrice(standardNode)).toBe('$0.1/Run')
expect(getNodeDisplayPrice(detailedNode)).toBe('$0.2/Run')
})
it('should handle various Tripo model version formats', () => {
const { getNodeDisplayPrice } = useNodePricing()
// Test different model version formats
const testCases = [
{ model: 'v2.0-20240919', expected: '$0.2/Run' },
{ model: 'v2.5-20250123', expected: '$0.2/Run' },
{ model: 'v1.4', expected: '$0.2/Run' },
{ model: 'unknown-model', expected: '$0.2/Run' }
]
testCases.forEach(({ model, expected }) => {
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: model },
{ name: 'texture_quality', value: 'standard' }
])
expect(getNodeDisplayPrice(node)).toBe(expected)
})
})
})
describe('Gemini and OpenAI Chat nodes', () => {
it('should return specific pricing for supported Gemini models', () => {
const { getNodeDisplayPrice } = useNodePricing()
const testCases = [
{
model: 'gemini-2.5-pro-preview-05-06',
expected: '$0.0035/$0.0008 per 1K tokens'
},
{
model: 'gemini-2.5-flash-preview-04-17',
expected: '$0.0015/$0.0004 per 1K tokens'
},
{ model: 'unknown-gemini-model', expected: 'Token-based' }
]
testCases.forEach(({ model, expected }) => {
const node = createMockNode('GeminiNode', [
{ name: 'model', value: model }
])
expect(getNodeDisplayPrice(node)).toBe(expected)
})
})
it('should return per-second pricing for Gemini Veo models', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('GeminiNode', [
{ name: 'model', value: 'veo-2.0-generate-001' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.5/second')
})
it('should return fallback for GeminiNode without model widget', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('GeminiNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('Token-based')
})
it('should return token-based pricing for OpenAIChatNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIChatNode', [
{ name: 'model', value: 'unknown-model' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('Token-based')
})
it('should return correct pricing for all exposed OpenAI models', () => {
const { getNodeDisplayPrice } = useNodePricing()
const testCases = [
{ model: 'o4-mini', expected: '$0.0011/$0.0044 per 1K tokens' },
{ model: 'o1-pro', expected: '$0.15/$0.60 per 1K tokens' },
{ model: 'o1', expected: '$0.015/$0.06 per 1K tokens' },
{ model: 'o3-mini', expected: '$0.0011/$0.0044 per 1K tokens' },
{ model: 'o3', expected: '$0.01/$0.04 per 1K tokens' },
{ model: 'gpt-4o', expected: '$0.0025/$0.01 per 1K tokens' },
{ model: 'gpt-4.1-nano', expected: '$0.0001/$0.0004 per 1K tokens' },
{ model: 'gpt-4.1-mini', expected: '$0.0004/$0.0016 per 1K tokens' },
{ model: 'gpt-4.1', expected: '$0.002/$0.008 per 1K tokens' }
]
testCases.forEach(({ model, expected }) => {
const node = createMockNode('OpenAIChatNode', [
{ name: 'model', value: model }
])
expect(getNodeDisplayPrice(node)).toBe(expected)
})
})
it('should handle model ordering correctly (specific before general)', () => {
const { getNodeDisplayPrice } = useNodePricing()
// Test that more specific patterns are matched before general ones
const testCases = [
{
model: 'gpt-4.1-nano-test',
expected: '$0.0001/$0.0004 per 1K tokens'
},
{
model: 'gpt-4.1-mini-test',
expected: '$0.0004/$0.0016 per 1K tokens'
},
{ model: 'gpt-4.1-test', expected: '$0.002/$0.008 per 1K tokens' },
{ model: 'o1-pro-test', expected: '$0.15/$0.60 per 1K tokens' },
{ model: 'o1-test', expected: '$0.015/$0.06 per 1K tokens' },
{ model: 'o3-mini-test', expected: '$0.0011/$0.0044 per 1K tokens' },
{ model: 'unknown-model', expected: 'Token-based' }
]
testCases.forEach(({ model, expected }) => {
const node = createMockNode('OpenAIChatNode', [
{ name: 'model', value: model }
])
expect(getNodeDisplayPrice(node)).toBe(expected)
})
})
it('should return fallback for OpenAIChatNode without model widget', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIChatNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('Token-based')
})
})
describe('Additional RunwayML edge cases', () => {
it('should handle edge cases for RunwayML duration-based pricing', () => {
const { getNodeDisplayPrice } = useNodePricing()
// Test edge cases
const testCases = [
{ duration: 0, expected: '$0.25/Run' }, // Falls back to 5 seconds (0 || 5)
{ duration: 1, expected: '$0.05/Run' },
{ duration: 30, expected: '$1.50/Run' }
]
testCases.forEach(({ duration, expected }) => {
const node = createMockNode('RunwayImageToVideoNodeGen3a', [
{ name: 'duration', value: duration }
])
expect(getNodeDisplayPrice(node)).toBe(expected)
})
})
it('should handle invalid duration values gracefully', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('RunwayImageToVideoNodeGen3a', [
{ name: 'duration', value: 'invalid-string' }
])
// When Number('invalid-string') returns NaN, it falls back to 5 seconds
expect(getNodeDisplayPrice(node)).toBe('$0.25/Run')
})
it('should handle missing duration widget gracefully', () => {
const { getNodeDisplayPrice } = useNodePricing()
const nodes = [
'RunwayImageToVideoNodeGen3a',
'RunwayImageToVideoNodeGen4',
'RunwayFirstLastFrameNode'
]
nodes.forEach((nodeType) => {
const node = createMockNode(nodeType, [])
expect(getNodeDisplayPrice(node)).toBe('$0.05/second')
})
})
})
describe('Complete Rodin node coverage', () => {
it('should return correct pricing for all Rodin variants', () => {
const { getNodeDisplayPrice } = useNodePricing()
const testCases = [
{ nodeType: 'Rodin3D_Regular', expected: '$0.4/Run' },
{ nodeType: 'Rodin3D_Sketch', expected: '$0.4/Run' },
{ nodeType: 'Rodin3D_Detail', expected: '$1.2/Run' },
{ nodeType: 'Rodin3D_Smooth', expected: '$1.2/Run' }
]
testCases.forEach(({ nodeType, expected }) => {
const node = createMockNode(nodeType)
expect(getNodeDisplayPrice(node)).toBe(expected)
})
})
})
describe('Comprehensive Tripo edge case testing', () => {
it('should handle TripoImageToModelNode with various model versions', () => {
const { getNodeDisplayPrice } = useNodePricing()
const testCases = [
{ model: 'v1.4-legacy', texture: 'standard', expected: '$0.3/Run' },
{ model: 'v2.0-20240919', texture: 'standard', expected: '$0.3/Run' },
{ model: 'v2.0-20240919', texture: 'detailed', expected: '$0.4/Run' },
{ model: 'v2.5-20250123', texture: 'standard', expected: '$0.3/Run' },
{ model: 'v2.5-20250123', texture: 'detailed', expected: '$0.4/Run' }
]
testCases.forEach(({ model, texture, expected }) => {
const node = createMockNode('TripoImageToModelNode', [
{ name: 'model_version', value: model },
{ name: 'texture_quality', value: texture }
])
expect(getNodeDisplayPrice(node)).toBe(expected)
})
})
it('should return correct fallback for TripoImageToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoImageToModelNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.3-0.4/Run (varies with model & texture quality)')
})
it('should handle missing texture quality widget', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v2.0-20240919' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.2/Run') // Default to standard texture pricing
})
it('should handle missing model version widget', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'texture_quality', value: 'detailed' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.2-0.3/Run (varies with model & texture quality)')
})
})
})
})