Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 

326 строки
12 KiB

  1. export const BILLING_UNITS = {
  2. perCall: 'per_call',
  3. per1MTokens: 'per_1m_tokens',
  4. };
  5. export const PRECONSUME_STRATEGIES = {
  6. exact: 'exact',
  7. minimum: 'minimum',
  8. };
  9. export const FALLBACKS = {
  10. reject: 'reject',
  11. max: 'max',
  12. default: 'default',
  13. };
  14. export const ROW_SOURCES = {
  15. manual: 'manual',
  16. generated: 'generated',
  17. };
  18. export const DIMENSION_SOURCE_TYPES = {
  19. request: 'request',
  20. derived: 'derived',
  21. advanced: 'advanced',
  22. };
  23. const RESERVED_KEYS = new Set([
  24. 'price',
  25. 'source',
  26. '__fallback__',
  27. 'price_formula',
  28. 'pricing_mode',
  29. 'fallback_strategy',
  30. 'schema_version',
  31. 'scope',
  32. 'billing_unit',
  33. 'preconsume_strategy',
  34. ]);
  35. export const createDefaultConfig = () => ({
  36. schema_version: 1,
  37. scope: 'model',
  38. billing_unit: BILLING_UNITS.perCall,
  39. preconsume_strategy: PRECONSUME_STRATEGIES.exact,
  40. dimensions: [
  41. {
  42. key: 'resolution',
  43. source: 'request.resolution',
  44. type: 'string',
  45. default: null,
  46. },
  47. ],
  48. table: [
  49. {
  50. resolution: '*',
  51. price: 0.1,
  52. source: ROW_SOURCES.manual,
  53. },
  54. ],
  55. fallback: {
  56. strategy: FALLBACKS.reject,
  57. },
  58. });
  59. export const validatePricingConfig = (config) => {
  60. const errors = [];
  61. if (!config || typeof config !== 'object') {
  62. return { ok: false, errors: ['config must be an object'] };
  63. }
  64. if (config.schema_version !== 1) errors.push('schema_version must be 1');
  65. if (config.scope !== 'model') errors.push('scope must be model');
  66. const billingUnit = config.billing_unit || BILLING_UNITS.perCall;
  67. const preconsume = config.preconsume_strategy || PRECONSUME_STRATEGIES.exact;
  68. if (!Object.values(BILLING_UNITS).includes(billingUnit)) errors.push('billing_unit is invalid');
  69. if (!Object.values(PRECONSUME_STRATEGIES).includes(preconsume)) errors.push('preconsume_strategy is invalid');
  70. if (billingUnit === BILLING_UNITS.perCall && preconsume !== PRECONSUME_STRATEGIES.exact) {
  71. errors.push('per_call requires exact preconsume_strategy');
  72. }
  73. if (billingUnit === BILLING_UNITS.per1MTokens && preconsume !== PRECONSUME_STRATEGIES.minimum) {
  74. errors.push('per_1m_tokens requires minimum preconsume_strategy');
  75. }
  76. if (!Array.isArray(config.dimensions) || config.dimensions.length === 0) errors.push('dimensions must not be empty');
  77. if (!Array.isArray(config.table) || config.table.length === 0) errors.push('table must not be empty');
  78. const dimensionKeys = new Set();
  79. for (const dim of config.dimensions || []) {
  80. if (!/^[a-z][a-z0-9_]{0,31}$/.test(dim.key || '')) errors.push(`dimension key "${dim.key || ''}" is invalid`);
  81. if (RESERVED_KEYS.has(dim.key)) errors.push(`dimension key "${dim.key}" is reserved`);
  82. if (dimensionKeys.has(dim.key)) errors.push(`dimension key "${dim.key}" is duplicated`);
  83. dimensionKeys.add(dim.key);
  84. if (!['string', 'number', 'boolean'].includes(dim.type)) errors.push(`dimension "${dim.key}" type is invalid`);
  85. if (dim.optional && (dim.default === null || dim.default === undefined)) {
  86. errors.push(`optional dimension "${dim.key}" requires a default value`);
  87. }
  88. if (!dim.optional && dim.default !== null && dim.default !== undefined) {
  89. errors.push(`required dimension "${dim.key}" must use null default`);
  90. }
  91. if (dim.optional && dim.default !== null && dim.default !== undefined && !valueMatchesType(dim.default, dim.type)) {
  92. errors.push(`dimension "${dim.key}" default does not match type`);
  93. }
  94. }
  95. const rowSignatures = new Set();
  96. const rowPatterns = [];
  97. for (const [index, row] of (config.table || []).entries()) {
  98. for (const key of Object.keys(row || {})) {
  99. if (!dimensionKeys.has(key) && key !== 'price' && key !== 'source') errors.push(`row ${index + 1} contains extra field "${key}"`);
  100. }
  101. for (const dim of config.dimensions || []) {
  102. if (!(dim.key in row)) {
  103. errors.push(`row ${index + 1} missing "${dim.key}"`);
  104. continue;
  105. }
  106. if (row[dim.key] !== '*' && !valueMatchesType(row[dim.key], dim.type)) {
  107. errors.push(`row ${index + 1} value for "${dim.key}" does not match type`);
  108. }
  109. }
  110. const price = Number(row.price);
  111. if (Number.isNaN(price) || price < 0) errors.push(`row ${index + 1} price must be >= 0`);
  112. if (!Object.values(ROW_SOURCES).includes(row.source)) errors.push(`row ${index + 1} source must be manual or generated`);
  113. const signature = (config.dimensions || []).map((dim) => String(row[dim.key])).join('\u0001');
  114. if (rowSignatures.has(signature)) errors.push(`row ${index + 1} duplicates another row`);
  115. rowSignatures.add(signature);
  116. rowPatterns.push({
  117. index,
  118. values: (config.dimensions || []).map((dim) => row[dim.key]),
  119. wildcards: (config.dimensions || []).filter((dim) => row[dim.key] === '*').length,
  120. });
  121. }
  122. for (let i = 0; i < rowPatterns.length; i += 1) {
  123. for (let j = i + 1; j < rowPatterns.length; j += 1) {
  124. if (rowPatterns[i].wildcards !== rowPatterns[j].wildcards) continue;
  125. if (patternsOverlap(rowPatterns[i].values, rowPatterns[j].values)) {
  126. errors.push(`rows ${rowPatterns[i].index + 1} and ${rowPatterns[j].index + 1} are ambiguous`);
  127. }
  128. }
  129. }
  130. const strategy = config.fallback?.strategy;
  131. if (!Object.values(FALLBACKS).includes(strategy)) errors.push('fallback.strategy is invalid');
  132. if (strategy === FALLBACKS.default && (config.fallback.default_price === undefined || Number(config.fallback.default_price) < 0)) {
  133. errors.push('fallback.default_price must be >= 0');
  134. }
  135. return { ok: errors.length === 0, errors };
  136. };
  137. export const generateRowsFromRatios = ({ dimensions, basePrice, ratios }) => {
  138. const dims = dimensions || [];
  139. if (dims.length === 0) return [];
  140. const build = (index, prefix, price) => {
  141. if (index >= dims.length) return [{ ...prefix, price: Number(Number(price).toFixed(6)), source: ROW_SOURCES.generated }];
  142. const dim = dims[index];
  143. const dimRatios = ratios?.[dim.key] || {};
  144. return Object.entries(dimRatios).flatMap(([value, ratio]) =>
  145. build(index + 1, { ...prefix, [dim.key]: parseDimensionValue(value, dim.type) }, price * Number(ratio || 1)),
  146. );
  147. };
  148. return build(0, {}, Number(basePrice || 0));
  149. };
  150. export const splitDimensionSource = (source) => {
  151. const text = String(source || '').trim();
  152. if (text.startsWith('request.')) return { sourceType: DIMENSION_SOURCE_TYPES.request, path: text.slice('request.'.length) };
  153. if (text.startsWith('derived.')) return { sourceType: DIMENSION_SOURCE_TYPES.derived, path: text.slice('derived.'.length) };
  154. return { sourceType: DIMENSION_SOURCE_TYPES.advanced, path: text };
  155. };
  156. export const composeDimensionSource = (sourceType, path) => {
  157. const text = String(path || '').trim();
  158. if (sourceType === DIMENSION_SOURCE_TYPES.request) {
  159. return text.startsWith('request.') ? text : `request.${text}`;
  160. }
  161. if (sourceType === DIMENSION_SOURCE_TYPES.derived) {
  162. return text.startsWith('derived.') ? text : `derived.${text}`;
  163. }
  164. return text;
  165. };
  166. export const countRatioEntries = (ratios) =>
  167. Object.values(ratios || {}).reduce((total, values) => total + Object.keys(values || {}).length, 0);
  168. export const estimateGeneratedRowCount = (dimensions, ratios) => {
  169. const dims = dimensions || [];
  170. if (dims.length === 0) return 0;
  171. return dims.reduce((total, dim) => {
  172. const count = Object.keys(ratios?.[dim.key] || {}).length;
  173. if (count === 0) return 0;
  174. return total * count;
  175. }, 1);
  176. };
  177. export const displayCurrencyToUSD = (amount, currencyConfig) => {
  178. const rate = Number(currencyConfig?.rate || 1);
  179. const value = Number(amount || 0);
  180. if (!Number.isFinite(rate) || rate <= 0) return value;
  181. return Number((value / rate).toFixed(6));
  182. };
  183. export const formatDisplayCurrency = (usdAmount, currencyConfig, digits = 6) => {
  184. const symbol = currencyConfig?.symbol || '$';
  185. const rate = Number(currencyConfig?.rate || 1);
  186. const value = Number(usdAmount || 0) * (Number.isFinite(rate) && rate > 0 ? rate : 1);
  187. return `${symbol}${value.toFixed(digits)}`;
  188. };
  189. export const mergeGeneratedRows = ({ existingRows, generatedRows, strategy }) => {
  190. if (strategy === 'replace_all') return [...(generatedRows || [])];
  191. const manualRows = (existingRows || []).filter((row) => row.source === ROW_SOURCES.manual);
  192. return [...manualRows, ...(generatedRows || [])];
  193. };
  194. export const importPricingCsv = (text, dimensions) => {
  195. const lines = String(text || '').trim().split(/\r?\n/).filter(Boolean);
  196. if (lines.length === 0) return { rows: [], errors: [] };
  197. const headers = parseCsvLine(lines[0]);
  198. const errors = [];
  199. const rows = lines.slice(1).map((line, index) => {
  200. const cells = parseCsvLine(line);
  201. const row = {};
  202. headers.forEach((header, i) => {
  203. row[header] = cells[i] ?? '';
  204. });
  205. for (const dim of dimensions || []) row[dim.key] = row[dim.key] === '*' ? '*' : parseDimensionValue(row[dim.key], dim.type);
  206. row.price = Number(row.price);
  207. row.source = row.source || ROW_SOURCES.manual;
  208. if (Number.isNaN(row.price)) errors.push(`line ${index + 2} has invalid price`);
  209. return row;
  210. });
  211. return { rows, errors };
  212. };
  213. export const exportPricingCsv = (rows, dimensions) => {
  214. const headers = [...(dimensions || []).map((dim) => dim.key), 'price', 'source'];
  215. const body = (rows || []).map((row) => headers.map((header) => escapeCsvValue(row[header] ?? '')).join(','));
  216. return [headers.join(','), ...body].join('\n');
  217. };
  218. export const previewLookup = (config, requestValues) => {
  219. const validation = validatePricingConfig(config);
  220. if (!validation.ok) return { mode: 'invalid', error: validation.errors.join('\n') };
  221. const matches = [];
  222. for (const row of config.table || []) {
  223. let wildcards = 0;
  224. let matched = true;
  225. for (const dim of config.dimensions || []) {
  226. const expected = row[dim.key];
  227. if (expected === '*') {
  228. wildcards += 1;
  229. continue;
  230. }
  231. if (String(expected) !== String(requestValues?.[dim.key])) {
  232. matched = false;
  233. break;
  234. }
  235. }
  236. if (matched) matches.push({ row, wildcards });
  237. }
  238. matches.sort((a, b) => a.wildcards - b.wildcards);
  239. if (matches.length > 1 && matches[0].wildcards === matches[1].wildcards) return { mode: 'reject', error: 'ambiguous matching rows' };
  240. if (matches.length > 0) {
  241. const best = matches[0];
  242. return {
  243. mode: 'matched',
  244. row: best.row,
  245. price: Number(best.row.price),
  246. snapshot: buildSnapshot(config, requestValues, Number(best.row.price), 'matched'),
  247. };
  248. }
  249. if (config.fallback.strategy === FALLBACKS.reject) return { mode: 'reject', error: 'no pricing row matched' };
  250. if (config.fallback.strategy === FALLBACKS.max) {
  251. const price = Math.max(...config.table.map((row) => Number(row.price || 0)));
  252. return { mode: 'fallback_max', price, snapshot: buildSnapshot(config, requestValues, price, 'fallback_max', FALLBACKS.max) };
  253. }
  254. const price = Number(config.fallback.default_price || 0);
  255. return { mode: 'fallback_default', price, snapshot: buildSnapshot(config, requestValues, price, 'fallback_default', FALLBACKS.default) };
  256. };
  257. export const parseDimensionValue = (value, type) => {
  258. if (value === '*') return value;
  259. if (type === 'number') return Number(value);
  260. if (type === 'boolean') return value === true || value === 'true';
  261. return value;
  262. };
  263. const valueMatchesType = (value, type) => {
  264. if (type === 'number') return typeof value === 'number' && !Number.isNaN(value);
  265. if (type === 'boolean') return typeof value === 'boolean';
  266. return typeof value === 'string';
  267. };
  268. const patternsOverlap = (left, right) =>
  269. left.every((leftValue, index) => leftValue === '*' || right[index] === '*' || String(leftValue) === String(right[index]));
  270. const parseCsvLine = (line) => line.split(',').map((item) => item.trim());
  271. const escapeCsvValue = (value) => {
  272. const text = String(value);
  273. if (!/[",\n]/.test(text)) return text;
  274. return `"${text.replaceAll('"', '""')}"`;
  275. };
  276. const buildSnapshot = (config, values, price, mode, fallbackStrategy) => {
  277. const snapshot = {};
  278. for (const dim of config.dimensions || []) snapshot[dim.key] = values?.[dim.key] ?? null;
  279. snapshot.price = price;
  280. snapshot.billing_unit = config.billing_unit || BILLING_UNITS.perCall;
  281. snapshot.pricing_mode = mode;
  282. if (fallbackStrategy) snapshot.fallback_strategy = fallbackStrategy;
  283. return snapshot;
  284. };