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

503 строки
14 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))
  69. errors.push('billing_unit is invalid');
  70. if (!Object.values(PRECONSUME_STRATEGIES).includes(preconsume))
  71. errors.push('preconsume_strategy is invalid');
  72. if (
  73. billingUnit === BILLING_UNITS.perCall &&
  74. preconsume !== PRECONSUME_STRATEGIES.exact
  75. ) {
  76. errors.push('per_call requires exact preconsume_strategy');
  77. }
  78. if (
  79. billingUnit === BILLING_UNITS.per1MTokens &&
  80. preconsume !== PRECONSUME_STRATEGIES.minimum
  81. ) {
  82. errors.push('per_1m_tokens requires minimum preconsume_strategy');
  83. }
  84. if (!Array.isArray(config.dimensions) || config.dimensions.length === 0)
  85. errors.push('dimensions must not be empty');
  86. if (!Array.isArray(config.table) || config.table.length === 0)
  87. errors.push('table must not be empty');
  88. const dimensionKeys = new Set();
  89. for (const dim of config.dimensions || []) {
  90. if (!/^[a-z][a-z0-9_]{0,31}$/.test(dim.key || ''))
  91. errors.push(`dimension key "${dim.key || ''}" is invalid`);
  92. if (RESERVED_KEYS.has(dim.key))
  93. errors.push(`dimension key "${dim.key}" is reserved`);
  94. if (dimensionKeys.has(dim.key))
  95. errors.push(`dimension key "${dim.key}" is duplicated`);
  96. dimensionKeys.add(dim.key);
  97. if (!['string', 'number', 'boolean'].includes(dim.type))
  98. errors.push(`dimension "${dim.key}" type is invalid`);
  99. if (dim.optional && (dim.default === null || dim.default === undefined)) {
  100. errors.push(`optional dimension "${dim.key}" requires a default value`);
  101. }
  102. if (!dim.optional && dim.default !== null && dim.default !== undefined) {
  103. errors.push(`required dimension "${dim.key}" must use null default`);
  104. }
  105. if (
  106. dim.optional &&
  107. dim.default !== null &&
  108. dim.default !== undefined &&
  109. !valueMatchesType(dim.default, dim.type)
  110. ) {
  111. errors.push(`dimension "${dim.key}" default does not match type`);
  112. }
  113. }
  114. const rowSignatures = new Set();
  115. const rowPatterns = [];
  116. for (const [index, row] of (config.table || []).entries()) {
  117. for (const key of Object.keys(row || {})) {
  118. if (!dimensionKeys.has(key) && key !== 'price' && key !== 'source')
  119. errors.push(`row ${index + 1} contains extra field "${key}"`);
  120. }
  121. for (const dim of config.dimensions || []) {
  122. if (!(dim.key in row)) {
  123. errors.push(`row ${index + 1} missing "${dim.key}"`);
  124. continue;
  125. }
  126. if (row[dim.key] !== '*' && !valueMatchesType(row[dim.key], dim.type)) {
  127. errors.push(
  128. `row ${index + 1} value for "${dim.key}" does not match type`,
  129. );
  130. }
  131. }
  132. const price = Number(row.price);
  133. if (Number.isNaN(price) || price < 0)
  134. errors.push(`row ${index + 1} price must be >= 0`);
  135. if (!Object.values(ROW_SOURCES).includes(row.source))
  136. errors.push(`row ${index + 1} source must be manual or generated`);
  137. const signature = (config.dimensions || [])
  138. .map((dim) => String(row[dim.key]))
  139. .join('\u0001');
  140. if (rowSignatures.has(signature))
  141. errors.push(`row ${index + 1} duplicates another row`);
  142. rowSignatures.add(signature);
  143. rowPatterns.push({
  144. index,
  145. values: (config.dimensions || []).map((dim) => row[dim.key]),
  146. wildcards: (config.dimensions || []).filter((dim) => row[dim.key] === '*')
  147. .length,
  148. });
  149. }
  150. for (let i = 0; i < rowPatterns.length; i += 1) {
  151. for (let j = i + 1; j < rowPatterns.length; j += 1) {
  152. if (rowPatterns[i].wildcards !== rowPatterns[j].wildcards) continue;
  153. if (patternsOverlap(rowPatterns[i].values, rowPatterns[j].values)) {
  154. errors.push(
  155. `rows ${rowPatterns[i].index + 1} and ${rowPatterns[j].index + 1} are ambiguous`,
  156. );
  157. }
  158. }
  159. }
  160. const strategy = config.fallback?.strategy;
  161. if (!Object.values(FALLBACKS).includes(strategy))
  162. errors.push('fallback.strategy is invalid');
  163. if (
  164. strategy === FALLBACKS.default &&
  165. (config.fallback.default_price === undefined ||
  166. Number(config.fallback.default_price) < 0)
  167. ) {
  168. errors.push('fallback.default_price must be >= 0');
  169. }
  170. return { ok: errors.length === 0, errors };
  171. };
  172. export const generateRowsFromRatios = ({ dimensions, basePrice, ratios }) => {
  173. const dims = dimensions || [];
  174. if (dims.length === 0) return [];
  175. const build = (index, prefix, price) => {
  176. if (index >= dims.length)
  177. return [
  178. {
  179. ...prefix,
  180. price: Number(Number(price).toFixed(6)),
  181. source: ROW_SOURCES.generated,
  182. },
  183. ];
  184. const dim = dims[index];
  185. const dimRatios = ratios?.[dim.key] || {};
  186. return Object.entries(dimRatios).flatMap(([value, ratio]) =>
  187. build(
  188. index + 1,
  189. { ...prefix, [dim.key]: parseDimensionValue(value, dim.type) },
  190. price * Number(ratio || 1),
  191. ),
  192. );
  193. };
  194. return build(0, {}, Number(basePrice || 0));
  195. };
  196. export const splitDimensionSource = (source) => {
  197. const text = String(source || '').trim();
  198. if (text.startsWith('request.'))
  199. return {
  200. sourceType: DIMENSION_SOURCE_TYPES.request,
  201. path: text.slice('request.'.length),
  202. };
  203. if (text.startsWith('derived.'))
  204. return {
  205. sourceType: DIMENSION_SOURCE_TYPES.derived,
  206. path: text.slice('derived.'.length),
  207. };
  208. return { sourceType: DIMENSION_SOURCE_TYPES.advanced, path: text };
  209. };
  210. export const composeDimensionSource = (sourceType, path) => {
  211. const text = String(path || '').trim();
  212. if (sourceType === DIMENSION_SOURCE_TYPES.request) {
  213. return text.startsWith('request.') ? text : `request.${text}`;
  214. }
  215. if (sourceType === DIMENSION_SOURCE_TYPES.derived) {
  216. return text.startsWith('derived.') ? text : `derived.${text}`;
  217. }
  218. return text;
  219. };
  220. export const countRatioEntries = (ratios) =>
  221. Object.values(ratios || {}).reduce(
  222. (total, values) => total + Object.keys(values || {}).length,
  223. 0,
  224. );
  225. export const estimateGeneratedRowCount = (dimensions, ratios) => {
  226. const dims = dimensions || [];
  227. if (dims.length === 0) return 0;
  228. return dims.reduce((total, dim) => {
  229. const count = Object.keys(ratios?.[dim.key] || {}).length;
  230. if (count === 0) return 0;
  231. return total * count;
  232. }, 1);
  233. };
  234. export const displayCurrencyToUSD = (amount, currencyConfig) => {
  235. const rate = Number(currencyConfig?.rate || 1);
  236. const value = Number(amount || 0);
  237. if (!Number.isFinite(rate) || rate <= 0) return value;
  238. return Number((value / rate).toFixed(12));
  239. };
  240. export const formatDisplayCurrencyNumber = (usdAmount, currencyConfig) => {
  241. const rate = Number(currencyConfig?.rate || 1);
  242. const value =
  243. Number(usdAmount || 0) * (Number.isFinite(rate) && rate > 0 ? rate : 1);
  244. return Number(value.toFixed(6));
  245. };
  246. export const formatDisplayCurrency = (
  247. usdAmount,
  248. currencyConfig,
  249. digits = 6,
  250. ) => {
  251. const symbol = currencyConfig?.symbol || '$';
  252. const value = formatDisplayCurrencyNumber(usdAmount, currencyConfig);
  253. return `${symbol}${value.toFixed(digits)}`;
  254. };
  255. export const pricingConfigToDisplayCurrency = (config, currencyConfig) => {
  256. const next = structuredClone(config || {});
  257. next.table = (next.table || []).map((row) => ({
  258. ...row,
  259. price: formatDisplayCurrencyNumber(row.price, currencyConfig),
  260. }));
  261. if (
  262. next.fallback?.strategy === FALLBACKS.default &&
  263. next.fallback.default_price !== undefined
  264. ) {
  265. next.fallback = {
  266. ...next.fallback,
  267. default_price: formatDisplayCurrencyNumber(
  268. next.fallback.default_price,
  269. currencyConfig,
  270. ),
  271. };
  272. }
  273. return next;
  274. };
  275. export const pricingConfigFromDisplayCurrency = (config, currencyConfig) => {
  276. const next = structuredClone(config || {});
  277. next.table = (next.table || []).map((row) => ({
  278. ...row,
  279. price: displayCurrencyToUSD(row.price, currencyConfig),
  280. }));
  281. if (
  282. next.fallback?.strategy === FALLBACKS.default &&
  283. next.fallback.default_price !== undefined
  284. ) {
  285. next.fallback = {
  286. ...next.fallback,
  287. default_price: displayCurrencyToUSD(
  288. next.fallback.default_price,
  289. currencyConfig,
  290. ),
  291. };
  292. }
  293. return next;
  294. };
  295. export const getBillingUnitLabel = (billingUnit, t = (key) => key) => {
  296. if (billingUnit === BILLING_UNITS.per1MTokens) return t('每 1M tokens');
  297. return t('每次调用');
  298. };
  299. export const getPriceUnitLabel = (
  300. billingUnit,
  301. symbol = '$',
  302. t = (key) => key,
  303. ) => `${symbol} / ${getBillingUnitLabel(billingUnit, t)}`;
  304. export const mergeGeneratedRows = ({
  305. existingRows,
  306. generatedRows,
  307. strategy,
  308. }) => {
  309. if (strategy === 'replace_all') return [...(generatedRows || [])];
  310. const manualRows = (existingRows || []).filter(
  311. (row) => row.source === ROW_SOURCES.manual,
  312. );
  313. return [...manualRows, ...(generatedRows || [])];
  314. };
  315. export const importPricingCsv = (text, dimensions) => {
  316. const lines = String(text || '')
  317. .trim()
  318. .split(/\r?\n/)
  319. .filter(Boolean);
  320. if (lines.length === 0) return { rows: [], errors: [] };
  321. const headers = parseCsvLine(lines[0]);
  322. const errors = [];
  323. const rows = lines.slice(1).map((line, index) => {
  324. const cells = parseCsvLine(line);
  325. const row = {};
  326. headers.forEach((header, i) => {
  327. row[header] = cells[i] ?? '';
  328. });
  329. for (const dim of dimensions || [])
  330. row[dim.key] =
  331. row[dim.key] === '*'
  332. ? '*'
  333. : parseDimensionValue(row[dim.key], dim.type);
  334. row.price = Number(row.price);
  335. row.source = row.source || ROW_SOURCES.manual;
  336. if (Number.isNaN(row.price))
  337. errors.push(`line ${index + 2} has invalid price`);
  338. return row;
  339. });
  340. return { rows, errors };
  341. };
  342. export const exportPricingCsv = (rows, dimensions) => {
  343. const headers = [
  344. ...(dimensions || []).map((dim) => dim.key),
  345. 'price',
  346. 'source',
  347. ];
  348. const body = (rows || []).map((row) =>
  349. headers.map((header) => escapeCsvValue(row[header] ?? '')).join(','),
  350. );
  351. return [headers.join(','), ...body].join('\n');
  352. };
  353. export const previewLookup = (config, requestValues) => {
  354. const validation = validatePricingConfig(config);
  355. if (!validation.ok)
  356. return { mode: 'invalid', error: validation.errors.join('\n') };
  357. const matches = [];
  358. for (const row of config.table || []) {
  359. let wildcards = 0;
  360. let matched = true;
  361. for (const dim of config.dimensions || []) {
  362. const expected = row[dim.key];
  363. if (expected === '*') {
  364. wildcards += 1;
  365. continue;
  366. }
  367. if (String(expected) !== String(requestValues?.[dim.key])) {
  368. matched = false;
  369. break;
  370. }
  371. }
  372. if (matched) matches.push({ row, wildcards });
  373. }
  374. matches.sort((a, b) => a.wildcards - b.wildcards);
  375. if (matches.length > 1 && matches[0].wildcards === matches[1].wildcards)
  376. return { mode: 'reject', error: 'ambiguous matching rows' };
  377. if (matches.length > 0) {
  378. const best = matches[0];
  379. return {
  380. mode: 'matched',
  381. row: best.row,
  382. price: Number(best.row.price),
  383. snapshot: buildSnapshot(
  384. config,
  385. requestValues,
  386. Number(best.row.price),
  387. 'matched',
  388. ),
  389. };
  390. }
  391. if (config.fallback.strategy === FALLBACKS.reject)
  392. return { mode: 'reject', error: 'no pricing row matched' };
  393. if (config.fallback.strategy === FALLBACKS.max) {
  394. const price = Math.max(
  395. ...config.table.map((row) => Number(row.price || 0)),
  396. );
  397. return {
  398. mode: 'fallback_max',
  399. price,
  400. snapshot: buildSnapshot(
  401. config,
  402. requestValues,
  403. price,
  404. 'fallback_max',
  405. FALLBACKS.max,
  406. ),
  407. };
  408. }
  409. const price = Number(config.fallback.default_price || 0);
  410. return {
  411. mode: 'fallback_default',
  412. price,
  413. snapshot: buildSnapshot(
  414. config,
  415. requestValues,
  416. price,
  417. 'fallback_default',
  418. FALLBACKS.default,
  419. ),
  420. };
  421. };
  422. export const parseDimensionValue = (value, type) => {
  423. if (value === '*') return value;
  424. if (type === 'number') return Number(value);
  425. if (type === 'boolean') return value === true || value === 'true';
  426. return value;
  427. };
  428. const valueMatchesType = (value, type) => {
  429. if (type === 'number')
  430. return typeof value === 'number' && !Number.isNaN(value);
  431. if (type === 'boolean') return typeof value === 'boolean';
  432. return typeof value === 'string';
  433. };
  434. const patternsOverlap = (left, right) =>
  435. left.every(
  436. (leftValue, index) =>
  437. leftValue === '*' ||
  438. right[index] === '*' ||
  439. String(leftValue) === String(right[index]),
  440. );
  441. const parseCsvLine = (line) => line.split(',').map((item) => item.trim());
  442. const escapeCsvValue = (value) => {
  443. const text = String(value);
  444. if (!/[",\n]/.test(text)) return text;
  445. return `"${text.replaceAll('"', '""')}"`;
  446. };
  447. const buildSnapshot = (config, values, price, mode, fallbackStrategy) => {
  448. const snapshot = {};
  449. for (const dim of config.dimensions || [])
  450. snapshot[dim.key] = values?.[dim.key] ?? null;
  451. snapshot.price = price;
  452. snapshot.billing_unit = config.billing_unit || BILLING_UNITS.perCall;
  453. snapshot.pricing_mode = mode;
  454. if (fallbackStrategy) snapshot.fallback_strategy = fallbackStrategy;
  455. return snapshot;
  456. };