|
|
|
@@ -0,0 +1,274 @@ |
|
|
|
export const BILLING_UNITS = { |
|
|
|
perCall: 'per_call', |
|
|
|
per1MTokens: 'per_1m_tokens', |
|
|
|
}; |
|
|
|
|
|
|
|
export const PRECONSUME_STRATEGIES = { |
|
|
|
exact: 'exact', |
|
|
|
minimum: 'minimum', |
|
|
|
}; |
|
|
|
|
|
|
|
export const FALLBACKS = { |
|
|
|
reject: 'reject', |
|
|
|
max: 'max', |
|
|
|
default: 'default', |
|
|
|
}; |
|
|
|
|
|
|
|
export const ROW_SOURCES = { |
|
|
|
manual: 'manual', |
|
|
|
generated: 'generated', |
|
|
|
}; |
|
|
|
|
|
|
|
const RESERVED_KEYS = new Set([ |
|
|
|
'price', |
|
|
|
'source', |
|
|
|
'__fallback__', |
|
|
|
'price_formula', |
|
|
|
'pricing_mode', |
|
|
|
'fallback_strategy', |
|
|
|
'schema_version', |
|
|
|
'scope', |
|
|
|
'billing_unit', |
|
|
|
'preconsume_strategy', |
|
|
|
]); |
|
|
|
|
|
|
|
export const createDefaultConfig = () => ({ |
|
|
|
schema_version: 1, |
|
|
|
scope: 'model', |
|
|
|
billing_unit: BILLING_UNITS.perCall, |
|
|
|
preconsume_strategy: PRECONSUME_STRATEGIES.exact, |
|
|
|
dimensions: [ |
|
|
|
{ |
|
|
|
key: 'resolution', |
|
|
|
source: 'request.resolution', |
|
|
|
type: 'string', |
|
|
|
default: null, |
|
|
|
}, |
|
|
|
], |
|
|
|
table: [ |
|
|
|
{ |
|
|
|
resolution: '*', |
|
|
|
price: 0.1, |
|
|
|
source: ROW_SOURCES.manual, |
|
|
|
}, |
|
|
|
], |
|
|
|
fallback: { |
|
|
|
strategy: FALLBACKS.reject, |
|
|
|
}, |
|
|
|
}); |
|
|
|
|
|
|
|
export const validatePricingConfig = (config) => { |
|
|
|
const errors = []; |
|
|
|
if (!config || typeof config !== 'object') { |
|
|
|
return { ok: false, errors: ['config must be an object'] }; |
|
|
|
} |
|
|
|
|
|
|
|
if (config.schema_version !== 1) errors.push('schema_version must be 1'); |
|
|
|
if (config.scope !== 'model') errors.push('scope must be model'); |
|
|
|
|
|
|
|
const billingUnit = config.billing_unit || BILLING_UNITS.perCall; |
|
|
|
const preconsume = config.preconsume_strategy || PRECONSUME_STRATEGIES.exact; |
|
|
|
if (!Object.values(BILLING_UNITS).includes(billingUnit)) errors.push('billing_unit is invalid'); |
|
|
|
if (!Object.values(PRECONSUME_STRATEGIES).includes(preconsume)) errors.push('preconsume_strategy is invalid'); |
|
|
|
if (billingUnit === BILLING_UNITS.perCall && preconsume !== PRECONSUME_STRATEGIES.exact) { |
|
|
|
errors.push('per_call requires exact preconsume_strategy'); |
|
|
|
} |
|
|
|
if (billingUnit === BILLING_UNITS.per1MTokens && preconsume !== PRECONSUME_STRATEGIES.minimum) { |
|
|
|
errors.push('per_1m_tokens requires minimum preconsume_strategy'); |
|
|
|
} |
|
|
|
|
|
|
|
if (!Array.isArray(config.dimensions) || config.dimensions.length === 0) errors.push('dimensions must not be empty'); |
|
|
|
if (!Array.isArray(config.table) || config.table.length === 0) errors.push('table must not be empty'); |
|
|
|
|
|
|
|
const dimensionKeys = new Set(); |
|
|
|
for (const dim of config.dimensions || []) { |
|
|
|
if (!/^[a-z][a-z0-9_]{0,31}$/.test(dim.key || '')) errors.push(`dimension key "${dim.key || ''}" is invalid`); |
|
|
|
if (RESERVED_KEYS.has(dim.key)) errors.push(`dimension key "${dim.key}" is reserved`); |
|
|
|
if (dimensionKeys.has(dim.key)) errors.push(`dimension key "${dim.key}" is duplicated`); |
|
|
|
dimensionKeys.add(dim.key); |
|
|
|
if (!['string', 'number', 'boolean'].includes(dim.type)) errors.push(`dimension "${dim.key}" type is invalid`); |
|
|
|
if (dim.optional && (dim.default === null || dim.default === undefined)) { |
|
|
|
errors.push(`optional dimension "${dim.key}" requires a default value`); |
|
|
|
} |
|
|
|
if (!dim.optional && dim.default !== null && dim.default !== undefined) { |
|
|
|
errors.push(`required dimension "${dim.key}" must use null default`); |
|
|
|
} |
|
|
|
if (dim.optional && dim.default !== null && dim.default !== undefined && !valueMatchesType(dim.default, dim.type)) { |
|
|
|
errors.push(`dimension "${dim.key}" default does not match type`); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
const rowSignatures = new Set(); |
|
|
|
const rowPatterns = []; |
|
|
|
for (const [index, row] of (config.table || []).entries()) { |
|
|
|
for (const key of Object.keys(row || {})) { |
|
|
|
if (!dimensionKeys.has(key) && key !== 'price' && key !== 'source') errors.push(`row ${index + 1} contains extra field "${key}"`); |
|
|
|
} |
|
|
|
for (const dim of config.dimensions || []) { |
|
|
|
if (!(dim.key in row)) { |
|
|
|
errors.push(`row ${index + 1} missing "${dim.key}"`); |
|
|
|
continue; |
|
|
|
} |
|
|
|
if (row[dim.key] !== '*' && !valueMatchesType(row[dim.key], dim.type)) { |
|
|
|
errors.push(`row ${index + 1} value for "${dim.key}" does not match type`); |
|
|
|
} |
|
|
|
} |
|
|
|
const price = Number(row.price); |
|
|
|
if (Number.isNaN(price) || price < 0) errors.push(`row ${index + 1} price must be >= 0`); |
|
|
|
if (!Object.values(ROW_SOURCES).includes(row.source)) errors.push(`row ${index + 1} source must be manual or generated`); |
|
|
|
|
|
|
|
const signature = (config.dimensions || []).map((dim) => String(row[dim.key])).join('\u0001'); |
|
|
|
if (rowSignatures.has(signature)) errors.push(`row ${index + 1} duplicates another row`); |
|
|
|
rowSignatures.add(signature); |
|
|
|
rowPatterns.push({ |
|
|
|
index, |
|
|
|
values: (config.dimensions || []).map((dim) => row[dim.key]), |
|
|
|
wildcards: (config.dimensions || []).filter((dim) => row[dim.key] === '*').length, |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
for (let i = 0; i < rowPatterns.length; i += 1) { |
|
|
|
for (let j = i + 1; j < rowPatterns.length; j += 1) { |
|
|
|
if (rowPatterns[i].wildcards !== rowPatterns[j].wildcards) continue; |
|
|
|
if (patternsOverlap(rowPatterns[i].values, rowPatterns[j].values)) { |
|
|
|
errors.push(`rows ${rowPatterns[i].index + 1} and ${rowPatterns[j].index + 1} are ambiguous`); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
const strategy = config.fallback?.strategy; |
|
|
|
if (!Object.values(FALLBACKS).includes(strategy)) errors.push('fallback.strategy is invalid'); |
|
|
|
if (strategy === FALLBACKS.default && (config.fallback.default_price === undefined || Number(config.fallback.default_price) < 0)) { |
|
|
|
errors.push('fallback.default_price must be >= 0'); |
|
|
|
} |
|
|
|
|
|
|
|
return { ok: errors.length === 0, errors }; |
|
|
|
}; |
|
|
|
|
|
|
|
export const generateRowsFromRatios = ({ dimensions, basePrice, ratios }) => { |
|
|
|
const dims = dimensions || []; |
|
|
|
if (dims.length === 0) return []; |
|
|
|
|
|
|
|
const build = (index, prefix, price) => { |
|
|
|
if (index >= dims.length) return [{ ...prefix, price: Number(Number(price).toFixed(6)), source: ROW_SOURCES.generated }]; |
|
|
|
const dim = dims[index]; |
|
|
|
const dimRatios = ratios?.[dim.key] || {}; |
|
|
|
return Object.entries(dimRatios).flatMap(([value, ratio]) => |
|
|
|
build(index + 1, { ...prefix, [dim.key]: parseDimensionValue(value, dim.type) }, price * Number(ratio || 1)), |
|
|
|
); |
|
|
|
}; |
|
|
|
|
|
|
|
return build(0, {}, Number(basePrice || 0)); |
|
|
|
}; |
|
|
|
|
|
|
|
export const mergeGeneratedRows = ({ existingRows, generatedRows, strategy }) => { |
|
|
|
if (strategy === 'replace_all') return [...(generatedRows || [])]; |
|
|
|
const manualRows = (existingRows || []).filter((row) => row.source === ROW_SOURCES.manual); |
|
|
|
return [...manualRows, ...(generatedRows || [])]; |
|
|
|
}; |
|
|
|
|
|
|
|
export const importPricingCsv = (text, dimensions) => { |
|
|
|
const lines = String(text || '').trim().split(/\r?\n/).filter(Boolean); |
|
|
|
if (lines.length === 0) return { rows: [], errors: [] }; |
|
|
|
|
|
|
|
const headers = parseCsvLine(lines[0]); |
|
|
|
const errors = []; |
|
|
|
const rows = lines.slice(1).map((line, index) => { |
|
|
|
const cells = parseCsvLine(line); |
|
|
|
const row = {}; |
|
|
|
headers.forEach((header, i) => { |
|
|
|
row[header] = cells[i] ?? ''; |
|
|
|
}); |
|
|
|
for (const dim of dimensions || []) row[dim.key] = row[dim.key] === '*' ? '*' : parseDimensionValue(row[dim.key], dim.type); |
|
|
|
row.price = Number(row.price); |
|
|
|
row.source = row.source || ROW_SOURCES.manual; |
|
|
|
if (Number.isNaN(row.price)) errors.push(`line ${index + 2} has invalid price`); |
|
|
|
return row; |
|
|
|
}); |
|
|
|
|
|
|
|
return { rows, errors }; |
|
|
|
}; |
|
|
|
|
|
|
|
export const exportPricingCsv = (rows, dimensions) => { |
|
|
|
const headers = [...(dimensions || []).map((dim) => dim.key), 'price', 'source']; |
|
|
|
const body = (rows || []).map((row) => headers.map((header) => escapeCsvValue(row[header] ?? '')).join(',')); |
|
|
|
return [headers.join(','), ...body].join('\n'); |
|
|
|
}; |
|
|
|
|
|
|
|
export const previewLookup = (config, requestValues) => { |
|
|
|
const validation = validatePricingConfig(config); |
|
|
|
if (!validation.ok) return { mode: 'invalid', error: validation.errors.join('\n') }; |
|
|
|
|
|
|
|
const matches = []; |
|
|
|
for (const row of config.table || []) { |
|
|
|
let wildcards = 0; |
|
|
|
let matched = true; |
|
|
|
for (const dim of config.dimensions || []) { |
|
|
|
const expected = row[dim.key]; |
|
|
|
if (expected === '*') { |
|
|
|
wildcards += 1; |
|
|
|
continue; |
|
|
|
} |
|
|
|
if (String(expected) !== String(requestValues?.[dim.key])) { |
|
|
|
matched = false; |
|
|
|
break; |
|
|
|
} |
|
|
|
} |
|
|
|
if (matched) matches.push({ row, wildcards }); |
|
|
|
} |
|
|
|
|
|
|
|
matches.sort((a, b) => a.wildcards - b.wildcards); |
|
|
|
if (matches.length > 1 && matches[0].wildcards === matches[1].wildcards) return { mode: 'reject', error: 'ambiguous matching rows' }; |
|
|
|
if (matches.length > 0) { |
|
|
|
const best = matches[0]; |
|
|
|
return { |
|
|
|
mode: 'matched', |
|
|
|
row: best.row, |
|
|
|
price: Number(best.row.price), |
|
|
|
snapshot: buildSnapshot(config, requestValues, Number(best.row.price), 'matched'), |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
if (config.fallback.strategy === FALLBACKS.reject) return { mode: 'reject', error: 'no pricing row matched' }; |
|
|
|
if (config.fallback.strategy === FALLBACKS.max) { |
|
|
|
const price = Math.max(...config.table.map((row) => Number(row.price || 0))); |
|
|
|
return { mode: 'fallback_max', price, snapshot: buildSnapshot(config, requestValues, price, 'fallback_max', FALLBACKS.max) }; |
|
|
|
} |
|
|
|
|
|
|
|
const price = Number(config.fallback.default_price || 0); |
|
|
|
return { mode: 'fallback_default', price, snapshot: buildSnapshot(config, requestValues, price, 'fallback_default', FALLBACKS.default) }; |
|
|
|
}; |
|
|
|
|
|
|
|
export const parseDimensionValue = (value, type) => { |
|
|
|
if (value === '*') return value; |
|
|
|
if (type === 'number') return Number(value); |
|
|
|
if (type === 'boolean') return value === true || value === 'true'; |
|
|
|
return value; |
|
|
|
}; |
|
|
|
|
|
|
|
const valueMatchesType = (value, type) => { |
|
|
|
if (type === 'number') return typeof value === 'number' && !Number.isNaN(value); |
|
|
|
if (type === 'boolean') return typeof value === 'boolean'; |
|
|
|
return typeof value === 'string'; |
|
|
|
}; |
|
|
|
|
|
|
|
const patternsOverlap = (left, right) => |
|
|
|
left.every((leftValue, index) => leftValue === '*' || right[index] === '*' || String(leftValue) === String(right[index])); |
|
|
|
|
|
|
|
const parseCsvLine = (line) => line.split(',').map((item) => item.trim()); |
|
|
|
|
|
|
|
const escapeCsvValue = (value) => { |
|
|
|
const text = String(value); |
|
|
|
if (!/[",\n]/.test(text)) return text; |
|
|
|
return `"${text.replaceAll('"', '""')}"`; |
|
|
|
}; |
|
|
|
|
|
|
|
const buildSnapshot = (config, values, price, mode, fallbackStrategy) => { |
|
|
|
const snapshot = {}; |
|
|
|
for (const dim of config.dimensions || []) snapshot[dim.key] = values?.[dim.key] ?? null; |
|
|
|
snapshot.price = price; |
|
|
|
snapshot.billing_unit = config.billing_unit || BILLING_UNITS.perCall; |
|
|
|
snapshot.pricing_mode = mode; |
|
|
|
if (fallbackStrategy) snapshot.fallback_strategy = fallbackStrategy; |
|
|
|
return snapshot; |
|
|
|
}; |