batch-budget-guard.js 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. 'use strict';
  2. const { queryBilling } = require('./billing-client');
  3. const { checkBudget, buildEstimateBands } = require('./budget-guard');
  4. /**
  5. * Re-checks the authenticated balance and actual trace spend before each
  6. * collection batch. The query is read-only; NewAPI still charges each real
  7. * gateway call independently.
  8. */
  9. function createBatchBudgetGuard({ input = {}, context = {}, operationFactory } = {}) {
  10. const budgetLimitCny = input.budgetCny ?? input.budgetLimitCny;
  11. const enabled = budgetLimitCny != null || input.enforceBudget === true || input.enforceBudget === 'true';
  12. let last = { enabled, status: 'disabled', allowed: true, checks: 0 };
  13. async function beforeBatch(batch = {}) {
  14. if (!enabled) return last;
  15. const operations = typeof operationFactory === 'function'
  16. ? operationFactory(batch)
  17. : [];
  18. if (!Array.isArray(operations) || operations.length === 0) return last;
  19. let result;
  20. try {
  21. const query = typeof input.queryBillingImpl === 'function' ? input.queryBillingImpl : queryBilling;
  22. result = await query({
  23. ...input,
  24. operations,
  25. include: ['balance', 'usage'],
  26. usageTraceId: context.usageTraceId,
  27. reportId: context.reportId,
  28. usageFilter: {
  29. ...(input.usageFilter || {}),
  30. usageTraceId: context.usageTraceId,
  31. ...(context.reportId ? { reportId: context.reportId } : {}),
  32. groupBy: ['usageTraceId'],
  33. page: 1,
  34. pageSize: 1
  35. },
  36. createPaymentQr: false
  37. });
  38. } catch (error) {
  39. last = {
  40. enabled,
  41. status: 'unknown',
  42. allowed: true,
  43. checks: last.checks + 1,
  44. warning: `批次预算检查暂时不可用:${error.message || String(error)}`
  45. };
  46. return last;
  47. }
  48. if (result?.status !== 'ok') {
  49. last = {
  50. enabled,
  51. status: 'unknown',
  52. allowed: true,
  53. checks: last.checks + 1,
  54. warning: result?.assistantMessage || '批次预算检查暂时不可用'
  55. };
  56. return last;
  57. }
  58. const estimate = result.data?.estimate || {};
  59. const usage = result.data?.usage?.totals || {};
  60. const bands = buildEstimateBands(estimate, { lowFactor: 1, highFactor: 1 });
  61. const budget = checkBudget({
  62. estimate,
  63. bands,
  64. balance: result.data?.balance || {},
  65. budgetLimitCny,
  66. actualCostCny: usage.costCny || 0,
  67. nextCostCny: estimate.amountCny || 0,
  68. rechargeBufferCny: input.rechargeBufferCny
  69. });
  70. last = {
  71. ...budget,
  72. enabled,
  73. checks: last.checks + 1,
  74. batchIndex: Number(batch.batchIndex || 0),
  75. keyword: batch.keyword || null,
  76. usageTraceId: context.usageTraceId,
  77. operations
  78. };
  79. return last;
  80. }
  81. beforeBatch.getState = () => ({ ...last });
  82. return { beforeBatch, snapshot: () => ({ ...last }) };
  83. }
  84. function findBudgetIssue(errors = []) {
  85. return errors.find(error => String(error?.kind || error?.errorKind || '') === 'budget') || null;
  86. }
  87. function budgetStoppedMessage(issue = {}) {
  88. const budget = issue.budget || issue;
  89. if (budget.status === 'needs_recharge') {
  90. return `批次预算检查发现当前余额不足,已停止新增调用。预计本批需 ¥${Number(budget.nextCostCny || 0).toFixed(2)},当前缺口约 ¥${Number(budget.shortfallCny || 0).toFixed(2)}。`;
  91. }
  92. return `批次预算检查已阻止继续执行,避免超过本地预算上限 ¥${Number(budget.budgetLimitCny || 0).toFixed(2)}。`;
  93. }
  94. module.exports = { createBatchBudgetGuard, findBudgetIssue, budgetStoppedMessage };