|
|
|
|
|
let allModels = []; |
|
|
let filteredModels = []; |
|
|
|
|
|
|
|
|
const vendorNameMap = { |
|
|
'qwen': 'Qwen', |
|
|
'meta-llama': 'Meta', |
|
|
'x-ai': 'xAI', |
|
|
'z-ai': 'Zhipu AI', |
|
|
'google': 'Google', |
|
|
'openai': 'OpenAI', |
|
|
'anthropic': 'Anthropic', |
|
|
'mistralai': 'Mistral AI', |
|
|
'deepseek': 'DeepSeek', |
|
|
'alibaba': 'Alibaba', |
|
|
'amazon': 'Amazon', |
|
|
'microsoft': 'Microsoft', |
|
|
'nvidia': 'NVIDIA', |
|
|
'cohere': 'Cohere', |
|
|
'ai21': 'AI21 Labs', |
|
|
'minimax': 'MiniMax', |
|
|
'moonshotai': 'Moonshot AI', |
|
|
'stepfun-ai': 'StepFun', |
|
|
'inclusionai': 'Inclusion AI', |
|
|
'deepcogito': 'Deep Cogito', |
|
|
'baidu': 'Baidu', |
|
|
'nousresearch': 'Nous Research', |
|
|
'arcee-ai': 'Arcee AI', |
|
|
'inception': 'Inception', |
|
|
'sao10k': 'Sao10K', |
|
|
'thedrummer': 'TheDrummer', |
|
|
'tngtech': 'TNG Technology', |
|
|
'meituan': 'Meituan' |
|
|
}; |
|
|
|
|
|
function normalizeVendorName(vendor) { |
|
|
return vendorNameMap[vendor] || vendor; |
|
|
} |
|
|
|
|
|
|
|
|
async function loadData() { |
|
|
try { |
|
|
const response = await fetch('quadrants.csv'); |
|
|
const csvText = await response.text(); |
|
|
|
|
|
Papa.parse(csvText, { |
|
|
header: true, |
|
|
dynamicTyping: true, |
|
|
complete: function(results) { |
|
|
allModels = results.data.filter(row => row.model_name); |
|
|
allModels.forEach(model => { |
|
|
model.displayVendor = normalizeVendorName(model.vendor); |
|
|
}); |
|
|
filteredModels = [...allModels]; |
|
|
initializeApp(); |
|
|
} |
|
|
}); |
|
|
} catch (error) { |
|
|
console.error('Error loading data:', error); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
let currentSort = { |
|
|
column: 'output_input_multiple', |
|
|
direction: 'desc' |
|
|
}; |
|
|
|
|
|
|
|
|
function initializeApp() { |
|
|
updateStats(); |
|
|
populateTable(); |
|
|
setupTableControls(); |
|
|
setupSorting(); |
|
|
createMultipleQuadrantChart(); |
|
|
createDistributionChart(); |
|
|
} |
|
|
|
|
|
|
|
|
function median(arr) { |
|
|
const sorted = [...arr].sort((a, b) => a - b); |
|
|
const mid = Math.floor(sorted.length / 2); |
|
|
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; |
|
|
} |
|
|
|
|
|
|
|
|
function updateStats() { |
|
|
const multiples = allModels.map(m => m.output_input_multiple); |
|
|
const medianMultiple = median(multiples); |
|
|
|
|
|
const equalPricing = allModels.filter(m => m.output_input_multiple <= 1).length; |
|
|
const highMultiple = allModels.filter(m => m.output_input_multiple >= 5).length; |
|
|
|
|
|
document.getElementById('total-models').textContent = allModels.length; |
|
|
document.getElementById('median-multiple').textContent = `${medianMultiple.toFixed(2)}x`; |
|
|
document.getElementById('median-multiple-stat').textContent = `${medianMultiple.toFixed(2)}x`; |
|
|
document.getElementById('median-line-value').textContent = `${medianMultiple.toFixed(2)}x`; |
|
|
document.getElementById('equal-pricing-count').textContent = equalPricing; |
|
|
document.getElementById('high-multiple-count').textContent = highMultiple; |
|
|
} |
|
|
|
|
|
|
|
|
function getCostTier(cost) { |
|
|
if (cost < 0.10) return 'cost-very-low'; |
|
|
if (cost < 0.50) return 'cost-low'; |
|
|
if (cost < 2.00) return 'cost-medium'; |
|
|
if (cost < 40.00) return 'cost-high'; |
|
|
return 'cost-very-high'; |
|
|
} |
|
|
|
|
|
|
|
|
function getMultipleTier(multiple) { |
|
|
if (multiple <= 1) return 'multiple-equal'; |
|
|
if (multiple < 2) return 'multiple-low'; |
|
|
if (multiple < 5) return 'multiple-medium'; |
|
|
if (multiple < 10) return 'multiple-high'; |
|
|
return 'multiple-very-high'; |
|
|
} |
|
|
|
|
|
|
|
|
function cleanModelName(modelName, vendor) { |
|
|
const patterns = [ |
|
|
new RegExp(`^${vendor}:\\s*`, 'i'), |
|
|
/^Qwen:\s*/i, /^Meta:\s*/i, /^Google:\s*/i, /^OpenAI:\s*/i, |
|
|
/^Anthropic:\s*/i, /^DeepSeek:\s*/i, /^Mistral:\s*/i, |
|
|
/^NVIDIA:\s*/i, /^Amazon:\s*/i, /^Microsoft:\s*/i, |
|
|
/^xAI:\s*/i, /^Zhipu AI:\s*/i, /^MoonshotAI:\s*/i, |
|
|
/^Moonshot AI:\s*/i, /^Alibaba:\s*/i |
|
|
]; |
|
|
let cleaned = modelName; |
|
|
for (const pattern of patterns) { |
|
|
cleaned = cleaned.replace(pattern, ''); |
|
|
} |
|
|
return cleaned; |
|
|
} |
|
|
|
|
|
|
|
|
function populateTable(models = filteredModels) { |
|
|
const tbody = document.getElementById('table-body'); |
|
|
|
|
|
const sortedModels = [...models].sort((a, b) => { |
|
|
let aVal = a[currentSort.column]; |
|
|
let bVal = b[currentSort.column]; |
|
|
|
|
|
if (typeof aVal === 'string') { |
|
|
aVal = aVal.toLowerCase(); |
|
|
bVal = bVal.toLowerCase(); |
|
|
} |
|
|
|
|
|
if (currentSort.direction === 'asc') { |
|
|
return aVal > bVal ? 1 : -1; |
|
|
} else { |
|
|
return aVal < bVal ? 1 : -1; |
|
|
} |
|
|
}); |
|
|
|
|
|
let lastVendor = null; |
|
|
tbody.innerHTML = sortedModels.map((m, index) => { |
|
|
const isNewVendor = m.displayVendor !== lastVendor; |
|
|
lastVendor = m.displayVendor; |
|
|
const vendorClass = isNewVendor ? 'vendor-group-start' : ''; |
|
|
|
|
|
return ` |
|
|
<tr class="${vendorClass}" data-vendor="${m.displayVendor}"> |
|
|
<td>${cleanModelName(m.model_name, m.displayVendor)}</td> |
|
|
<td class="vendor-cell">${m.displayVendor}</td> |
|
|
<td class="${getCostTier(m.input_price_usd_per_m)}" style="font-weight: 600;"> |
|
|
$${m.input_price_usd_per_m.toFixed(2)} |
|
|
</td> |
|
|
<td class="${getCostTier(m.output_price_usd_per_m)}" style="font-weight: 600;"> |
|
|
$${m.output_price_usd_per_m.toFixed(2)} |
|
|
</td> |
|
|
<td class="${getMultipleTier(m.output_input_multiple)}" style="font-weight: 600;"> |
|
|
${m.output_input_multiple.toFixed(2)}x |
|
|
</td> |
|
|
<td class="${getCostTier(m.avg_cost)}" style="font-weight: 600;"> |
|
|
$${m.avg_cost.toFixed(2)} |
|
|
</td> |
|
|
</tr> |
|
|
`; |
|
|
}).join(''); |
|
|
} |
|
|
|
|
|
|
|
|
function setupSorting() { |
|
|
const headers = document.querySelectorAll('th.sortable'); |
|
|
|
|
|
headers.forEach(header => { |
|
|
header.addEventListener('click', () => { |
|
|
const column = header.dataset.sort; |
|
|
|
|
|
if (currentSort.column === column) { |
|
|
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc'; |
|
|
} else { |
|
|
currentSort.column = column; |
|
|
currentSort.direction = 'asc'; |
|
|
} |
|
|
|
|
|
headers.forEach(h => { |
|
|
h.classList.remove('sort-asc', 'sort-desc'); |
|
|
}); |
|
|
header.classList.add(`sort-${currentSort.direction}`); |
|
|
|
|
|
populateTable(filteredModels); |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
const initialHeader = document.querySelector(`th[data-sort="${currentSort.column}"]`); |
|
|
if (initialHeader) { |
|
|
initialHeader.classList.add('sort-desc'); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function setupTableControls() { |
|
|
const searchInput = document.getElementById('search'); |
|
|
const multipleFilter = document.getElementById('multiple-filter'); |
|
|
|
|
|
function applyFilters() { |
|
|
const searchTerm = searchInput.value.toLowerCase(); |
|
|
const multipleCategory = multipleFilter.value; |
|
|
|
|
|
filteredModels = allModels.filter(m => { |
|
|
const matchesSearch = !searchTerm || |
|
|
m.model_name.toLowerCase().includes(searchTerm) || |
|
|
m.displayVendor.toLowerCase().includes(searchTerm); |
|
|
|
|
|
let matchesMultiple = true; |
|
|
if (multipleCategory === 'equal') matchesMultiple = m.output_input_multiple <= 1; |
|
|
else if (multipleCategory === 'low') matchesMultiple = m.output_input_multiple < 2; |
|
|
else if (multipleCategory === 'medium') matchesMultiple = m.output_input_multiple >= 2 && m.output_input_multiple < 5; |
|
|
else if (multipleCategory === 'high') matchesMultiple = m.output_input_multiple >= 5 && m.output_input_multiple < 10; |
|
|
else if (multipleCategory === 'very-high') matchesMultiple = m.output_input_multiple >= 10; |
|
|
|
|
|
return matchesSearch && matchesMultiple; |
|
|
}); |
|
|
|
|
|
populateTable(filteredModels); |
|
|
} |
|
|
|
|
|
searchInput.addEventListener('input', applyFilters); |
|
|
multipleFilter.addEventListener('change', applyFilters); |
|
|
} |
|
|
|
|
|
|
|
|
function createMultipleQuadrantChart() { |
|
|
const ctx = document.getElementById('multipleQuadrantChart').getContext('2d'); |
|
|
|
|
|
const multiples = allModels.map(m => m.output_input_multiple); |
|
|
const costs = allModels.map(m => m.avg_cost); |
|
|
const medianMultiple = median(multiples); |
|
|
const medianCost = median(costs); |
|
|
|
|
|
|
|
|
const quadrants = { |
|
|
'Low Multiple / Low Cost': { color: '#10b981', models: [] }, |
|
|
'High Multiple / Low Cost': { color: '#f59e0b', models: [] }, |
|
|
'Low Multiple / High Cost': { color: '#2563eb', models: [] }, |
|
|
'High Multiple / High Cost': { color: '#ef4444', models: [] } |
|
|
}; |
|
|
|
|
|
allModels.forEach(m => { |
|
|
const isLowMultiple = m.output_input_multiple < medianMultiple; |
|
|
const isLowCost = m.avg_cost < medianCost; |
|
|
|
|
|
if (isLowMultiple && isLowCost) quadrants['Low Multiple / Low Cost'].models.push(m); |
|
|
else if (!isLowMultiple && isLowCost) quadrants['High Multiple / Low Cost'].models.push(m); |
|
|
else if (isLowMultiple && !isLowCost) quadrants['Low Multiple / High Cost'].models.push(m); |
|
|
else quadrants['High Multiple / High Cost'].models.push(m); |
|
|
}); |
|
|
|
|
|
const datasets = Object.keys(quadrants).map(quadrant => { |
|
|
const q = quadrants[quadrant]; |
|
|
return { |
|
|
label: quadrant, |
|
|
data: q.models.map(m => ({ |
|
|
x: m.output_input_multiple, |
|
|
y: m.avg_cost, |
|
|
model: m |
|
|
})), |
|
|
backgroundColor: q.color + '80', |
|
|
borderColor: q.color, |
|
|
borderWidth: 2, |
|
|
pointRadius: 5, |
|
|
pointHoverRadius: 8 |
|
|
}; |
|
|
}); |
|
|
|
|
|
new Chart(ctx, { |
|
|
type: 'scatter', |
|
|
data: { datasets }, |
|
|
options: { |
|
|
responsive: true, |
|
|
maintainAspectRatio: false, |
|
|
plugins: { |
|
|
legend: { |
|
|
display: true, |
|
|
position: 'top' |
|
|
}, |
|
|
tooltip: { |
|
|
callbacks: { |
|
|
label: function(context) { |
|
|
const model = context.raw.model; |
|
|
return [ |
|
|
model.model_name, |
|
|
`Vendor: ${model.displayVendor}`, |
|
|
`Out/In Multiple: ${model.output_input_multiple.toFixed(2)}x`, |
|
|
`Input: $${model.input_price_usd_per_m.toFixed(2)}/M`, |
|
|
`Output: $${model.output_price_usd_per_m.toFixed(2)}/M`, |
|
|
`Avg: $${model.avg_cost.toFixed(2)}/M` |
|
|
]; |
|
|
} |
|
|
} |
|
|
}, |
|
|
annotation: { |
|
|
annotations: { |
|
|
verticalLine: { |
|
|
type: 'line', |
|
|
xMin: medianMultiple, |
|
|
xMax: medianMultiple, |
|
|
borderColor: '#64748b', |
|
|
borderWidth: 2, |
|
|
borderDash: [5, 5], |
|
|
label: { |
|
|
display: true, |
|
|
content: `Median Multiple: ${medianMultiple.toFixed(2)}x`, |
|
|
position: 'start' |
|
|
} |
|
|
}, |
|
|
horizontalLine: { |
|
|
type: 'line', |
|
|
yMin: medianCost, |
|
|
yMax: medianCost, |
|
|
borderColor: '#64748b', |
|
|
borderWidth: 2, |
|
|
borderDash: [5, 5], |
|
|
label: { |
|
|
display: true, |
|
|
content: `Median Cost: $${medianCost.toFixed(2)}`, |
|
|
position: 'end' |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
}, |
|
|
scales: { |
|
|
x: { |
|
|
title: { |
|
|
display: true, |
|
|
text: 'Output/Input Price Multiple' |
|
|
} |
|
|
}, |
|
|
y: { |
|
|
type: 'logarithmic', |
|
|
title: { |
|
|
display: true, |
|
|
text: 'Average Cost ($/M tokens, log scale)' |
|
|
} |
|
|
} |
|
|
} |
|
|
}, |
|
|
plugins: [window['chartjs-plugin-annotation']] |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
function createDistributionChart() { |
|
|
const ctx = document.getElementById('multipleDistribution').getContext('2d'); |
|
|
|
|
|
const bins = { |
|
|
'≤1x (Equal)': allModels.filter(m => m.output_input_multiple <= 1).length, |
|
|
'<2x (Low)': allModels.filter(m => m.output_input_multiple > 1 && m.output_input_multiple < 2).length, |
|
|
'2-5x (Medium)': allModels.filter(m => m.output_input_multiple >= 2 && m.output_input_multiple < 5).length, |
|
|
'5-10x (High)': allModels.filter(m => m.output_input_multiple >= 5 && m.output_input_multiple < 10).length, |
|
|
'10x+ (Very High)': allModels.filter(m => m.output_input_multiple >= 10).length |
|
|
}; |
|
|
|
|
|
new Chart(ctx, { |
|
|
type: 'bar', |
|
|
data: { |
|
|
labels: Object.keys(bins), |
|
|
datasets: [{ |
|
|
label: 'Number of Models', |
|
|
data: Object.values(bins), |
|
|
backgroundColor: [ |
|
|
'#10b981', |
|
|
'#84cc16', |
|
|
'#fde047', |
|
|
'#f97316', |
|
|
'#ef4444' |
|
|
], |
|
|
borderColor: [ |
|
|
'#059669', |
|
|
'#65a30d', |
|
|
'#eab308', |
|
|
'#ea580c', |
|
|
'#dc2626' |
|
|
], |
|
|
borderWidth: 2 |
|
|
}] |
|
|
}, |
|
|
options: { |
|
|
responsive: true, |
|
|
maintainAspectRatio: false, |
|
|
plugins: { |
|
|
legend: { |
|
|
display: false |
|
|
}, |
|
|
tooltip: { |
|
|
callbacks: { |
|
|
label: function(context) { |
|
|
const percentage = ((context.parsed.y / allModels.length) * 100).toFixed(1); |
|
|
return `${context.parsed.y} models (${percentage}%)`; |
|
|
} |
|
|
} |
|
|
} |
|
|
}, |
|
|
scales: { |
|
|
y: { |
|
|
beginAtZero: true, |
|
|
title: { |
|
|
display: true, |
|
|
text: 'Number of Models' |
|
|
} |
|
|
}, |
|
|
x: { |
|
|
title: { |
|
|
display: true, |
|
|
text: 'Output/Input Multiple Range' |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', loadData); |
|
|
|