@aglyn/plugins-ai 1.0.0-beta.219 → 1.0.0-beta.221

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/package.json +18 -18
  2. package/src/lib/billing/ai-month-meter.d.ts +51 -0
  3. package/src/lib/billing/ai-month-meter.js +89 -0
  4. package/src/lib/billing/ai-month-meter.js.map +1 -0
  5. package/src/lib/billing/ai-overage-close.d.ts +11 -33
  6. package/src/lib/billing/ai-overage-close.js +4 -3
  7. package/src/lib/billing/ai-overage-close.js.map +1 -1
  8. package/src/lib/billing/ai-overage-cutover.d.ts +28 -12
  9. package/src/lib/billing/ai-overage-cutover.js +1 -28
  10. package/src/lib/billing/ai-overage-cutover.js.map +1 -1
  11. package/src/lib/billing/ai-overage-ledger.js +1 -1
  12. package/src/lib/billing/ai-overage-ledger.js.map +1 -1
  13. package/src/lib/billing/ai-overage-standing.js +1 -1
  14. package/src/lib/billing/ai-overage-standing.js.map +1 -1
  15. package/src/lib/components/ai-allotment-editor.component.js +1 -1
  16. package/src/lib/components/ai-allotment-editor.component.js.map +1 -1
  17. package/src/lib/components/ai-assist-provider.component.js +3 -2
  18. package/src/lib/components/ai-assist-provider.component.js.map +1 -1
  19. package/src/lib/components/ai-automation-zones.d.ts +63 -0
  20. package/src/lib/components/ai-automation-zones.js +31 -0
  21. package/src/lib/components/ai-automation-zones.js.map +1 -0
  22. package/src/lib/components/ai-describe-automation.component.d.ts +1 -1
  23. package/src/lib/components/ai-describe-automation.component.js.map +1 -1
  24. package/src/lib/components/ai-describe-button.component.d.ts +6 -3
  25. package/src/lib/components/ai-describe-button.component.js +4 -1
  26. package/src/lib/components/ai-describe-button.component.js.map +1 -1
  27. package/src/lib/components/ai-explain-automation.component.d.ts +1 -1
  28. package/src/lib/components/ai-explain-automation.component.js.map +1 -1
  29. package/src/lib/components/billing-ai-allotments.component.js +1 -1
  30. package/src/lib/components/billing-ai-allotments.component.js.map +1 -1
  31. package/src/lib/components/billing-assist-overage-card.component.js +1 -1
  32. package/src/lib/components/billing-assist-overage-card.component.js.map +1 -1
  33. package/src/lib/constants.d.ts +5 -0
  34. package/src/lib/constants.js +4 -0
  35. package/src/lib/constants.js.map +1 -1
  36. package/src/lib/declarations.js +2 -0
  37. package/src/lib/declarations.js.map +1 -1
  38. package/src/lib/declarations.server.js +24 -10
  39. package/src/lib/declarations.server.js.map +1 -1
  40. package/src/lib/insights/ai-dataset-catalog.d.ts +49 -0
  41. package/src/lib/insights/ai-dataset-catalog.js +57 -0
  42. package/src/lib/insights/ai-dataset-catalog.js.map +1 -0
  43. package/src/lib/insights/ai-figure-readers.d.ts +6 -39
  44. package/src/lib/insights/ai-figure-readers.js +2 -391
  45. package/src/lib/insights/ai-figure-readers.js.map +1 -1
  46. package/src/lib/insights/ai-insight-digest.d.ts +10 -1
  47. package/src/lib/insights/ai-insight-digest.js +16 -4
  48. package/src/lib/insights/ai-insight-digest.js.map +1 -1
  49. package/src/lib/insights/ai-insight-readers.d.ts +1 -1
  50. package/src/lib/insights/ai-insight-readers.js.map +1 -1
  51. package/src/lib/jobs/ai-job-component-step.d.ts +6 -1
  52. package/src/lib/jobs/ai-job-component-step.js +23 -6
  53. package/src/lib/jobs/ai-job-component-step.js.map +1 -1
  54. package/src/lib/jobs/ai-job-draft-ids.js +11 -0
  55. package/src/lib/jobs/ai-job-draft-ids.js.map +1 -1
  56. package/src/lib/jobs/ai-job-email-step.d.ts +5 -1
  57. package/src/lib/jobs/ai-job-email-step.js +10 -1
  58. package/src/lib/jobs/ai-job-email-step.js.map +1 -1
  59. package/src/lib/jobs/ai-job-form-step.d.ts +17 -1
  60. package/src/lib/jobs/ai-job-form-step.js +60 -8
  61. package/src/lib/jobs/ai-job-form-step.js.map +1 -1
  62. package/src/lib/jobs/ai-job-generation.d.ts +21 -0
  63. package/src/lib/jobs/ai-job-generation.js +53 -1
  64. package/src/lib/jobs/ai-job-generation.js.map +1 -1
  65. package/src/lib/jobs/ai-job-insight-step.js +1 -1
  66. package/src/lib/jobs/ai-job-insight-step.js.map +1 -1
  67. package/src/lib/jobs/ai-job-layout-step.d.ts +15 -4
  68. package/src/lib/jobs/ai-job-layout-step.js +45 -12
  69. package/src/lib/jobs/ai-job-layout-step.js.map +1 -1
  70. package/src/lib/jobs/ai-job-plan-conformance.d.ts +5 -0
  71. package/src/lib/jobs/ai-job-plan-conformance.js +7 -1
  72. package/src/lib/jobs/ai-job-plan-conformance.js.map +1 -1
  73. package/src/lib/jobs/ai-job-plan-creations.d.ts +80 -0
  74. package/src/lib/jobs/ai-job-plan-creations.js +199 -0
  75. package/src/lib/jobs/ai-job-plan-creations.js.map +1 -0
  76. package/src/lib/jobs/ai-job-plan-step.d.ts +8 -2
  77. package/src/lib/jobs/ai-job-plan-step.js +20 -6
  78. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  79. package/src/lib/jobs/ai-job-site-step.d.ts +2 -0
  80. package/src/lib/jobs/ai-job-site-step.js +6 -6
  81. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  82. package/src/lib/jobs/ai-job-template-step.d.ts +9 -6
  83. package/src/lib/jobs/ai-job-template-step.js +72 -15
  84. package/src/lib/jobs/ai-job-template-step.js.map +1 -1
  85. package/src/lib/jobs/ai-jobs.js +1 -1
  86. package/src/lib/jobs/ai-jobs.js.map +1 -1
  87. package/src/lib/jobs/ai-workflow-records.d.ts +5 -4
  88. package/src/lib/jobs/ai-workflow-records.js +26 -22
  89. package/src/lib/jobs/ai-workflow-records.js.map +1 -1
  90. package/src/lib/model/ai-allotments.d.ts +16 -3
  91. package/src/lib/model/ai-allotments.js +34 -11
  92. package/src/lib/model/ai-allotments.js.map +1 -1
  93. package/src/lib/model/ai-job-creations.d.ts +55 -0
  94. package/src/lib/model/ai-job-creations.js +85 -0
  95. package/src/lib/model/ai-job-creations.js.map +1 -0
  96. package/src/lib/model/ai-page-job.d.ts +3 -2
  97. package/src/lib/model/ai-page-job.js +4 -3
  98. package/src/lib/model/ai-page-job.js.map +1 -1
  99. package/src/lib/model/ai-site-job.d.ts +4 -2
  100. package/src/lib/model/ai-site-job.js +8 -4
  101. package/src/lib/model/ai-site-job.js.map +1 -1
  102. package/src/lib/model/ai-template-subjects.d.ts +10 -0
  103. package/src/lib/model/ai-template-subjects.js +14 -0
  104. package/src/lib/model/ai-template-subjects.js.map +1 -1
  105. package/src/lib/model/ai-tokens.js +1 -1
  106. package/src/lib/model/ai-tokens.js.map +1 -1
  107. package/src/lib/model/ai-usage-by-user.js +1 -1
  108. package/src/lib/model/ai-usage-by-user.js.map +1 -1
  109. package/src/lib/providers/catalog.js +1 -1
  110. package/src/lib/providers/catalog.js.map +1 -1
  111. package/src/lib/providers/model-choice.js +1 -1
  112. package/src/lib/providers/model-choice.js.map +1 -1
  113. package/src/lib/runtime/ai-device-audit.d.ts +1 -1
  114. package/src/lib/runtime/ai-device-audit.js +1 -1
  115. package/src/lib/runtime/ai-device-audit.js.map +1 -1
  116. package/src/lib/runtime/ai-eval-live.js +1 -1
  117. package/src/lib/runtime/ai-eval-live.js.map +1 -1
  118. package/src/lib/runtime/ai-eval.d.ts +1 -1
  119. package/src/lib/runtime/ai-eval.js.map +1 -1
  120. package/src/lib/runtime/ai-gate.js +1 -1
  121. package/src/lib/runtime/ai-gate.js.map +1 -1
  122. package/src/lib/runtime/ai-palette.d.ts +1 -1
  123. package/src/lib/runtime/ai-palette.generated.d.ts +1 -1
  124. package/src/lib/runtime/ai-palette.generated.js +1 -1
  125. package/src/lib/runtime/ai-palette.generated.js.map +1 -1
  126. package/src/lib/runtime/ai-palette.js.map +1 -1
  127. package/src/lib/runtime/site-inventory.js +28 -22
  128. package/src/lib/runtime/site-inventory.js.map +1 -1
  129. package/src/lib/server/ai-admin-org.js +1 -1
  130. package/src/lib/server/ai-admin-org.js.map +1 -1
  131. package/src/lib/server/ai-admin-orgs-spend.js +1 -1
  132. package/src/lib/server/ai-admin-orgs-spend.js.map +1 -1
  133. package/src/lib/server/ai-admin-signals.js +1 -1
  134. package/src/lib/server/ai-admin-signals.js.map +1 -1
  135. package/src/lib/server/ai-allotments.js +1 -1
  136. package/src/lib/server/ai-allotments.js.map +1 -1
  137. package/src/lib/server/ai-assist.js +2 -2
  138. package/src/lib/server/ai-assist.js.map +1 -1
  139. package/src/lib/server/assist-chat.d.ts +1 -1
  140. package/src/lib/server/assist-chat.js +2 -2
  141. package/src/lib/server/assist-chat.js.map +1 -1
  142. package/src/lib/server/billing-credits.d.ts +5 -0
  143. package/src/lib/server/billing-credits.js +8 -2
  144. package/src/lib/server/billing-credits.js.map +1 -1
  145. package/src/lib/server/billing-overage.js +2 -2
  146. package/src/lib/server/billing-overage.js.map +1 -1
  147. package/src/lib/testing/stand-in-dataset-index.d.ts +20 -0
  148. package/src/lib/testing/stand-in-dataset-index.js +80 -0
  149. package/src/lib/testing/stand-in-dataset-index.js.map +1 -0
  150. package/src/lib/usage/ai-allotment-alerts.d.ts +10 -7
  151. package/src/lib/usage/ai-allotment-alerts.js +31 -14
  152. package/src/lib/usage/ai-allotment-alerts.js.map +1 -1
  153. package/src/lib/usage/ai-quota-checks.d.ts +37 -0
  154. package/src/lib/usage/ai-quota-checks.js +69 -0
  155. package/src/lib/usage/ai-quota-checks.js.map +1 -0
  156. package/src/lib/usage/ai-usage-alerts.js +22 -10
  157. package/src/lib/usage/ai-usage-alerts.js.map +1 -1
  158. package/src/lib/usage/ai-usage-by-user.js +1 -1
  159. package/src/lib/usage/ai-usage-by-user.js.map +1 -1
  160. package/src/lib/usage/assist-ceiling.d.ts +2 -2
  161. package/src/lib/usage/assist-ceiling.js +4 -4
  162. package/src/lib/usage/assist-ceiling.js.map +1 -1
  163. package/src/lib/usage/assist-credits.d.ts +494 -0
  164. package/src/lib/usage/assist-credits.js +524 -0
  165. package/src/lib/usage/assist-credits.js.map +1 -0
  166. package/src/lib/usage/assist-free-taste.d.ts +1 -1
  167. package/src/lib/usage/assist-free-taste.js +1 -1
  168. package/src/lib/usage/assist-free-taste.js.map +1 -1
  169. package/src/lib/usage/assist-signal-mining.js +1 -1
  170. package/src/lib/usage/assist-signal-mining.js.map +1 -1
  171. package/src/lib/usage/assist-spend-guards.d.ts +153 -0
  172. package/src/lib/usage/assist-spend-guards.js +182 -0
  173. package/src/lib/usage/assist-spend-guards.js.map +1 -0
  174. package/src/lib/usage/assist-usage.d.ts +1 -1
  175. package/src/lib/usage/assist-usage.js +1 -1
  176. package/src/lib/usage/assist-usage.js.map +1 -1
  177. package/src/lib/usage/staff-org-ai.js +1 -1
  178. package/src/lib/usage/staff-org-ai.js.map +1 -1
  179. package/src/lib/usage-axes.d.ts +10 -0
  180. package/src/lib/usage-axes.js +40 -2
  181. package/src/lib/usage-axes.js.map +1 -1
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { ASSIST_HARD_CAP_CONTROL_LABEL, ASSIST_HARD_CAP_CONTROL_LOCATION, assistBandRefuses, assistCreditsFromUsd, resolveAssistCreditBudget, resolveAssistHardCap, resolveAssistOverageRateUsdPer1k } from "./assist-credits.js";
17
+ import { AI_ASSIST_SPEND_LINE_ID } from "../usage-axes.js";
18
+ /** The guard key the credits band is warned under. */ export const AI_CREDITS_QUOTA_KEY = 'assistCredits';
19
+ /**
20
+ * The AI credits band the WORKSPACE is warned about (AGL-2898), as a quota
21
+ * check the usage-alerts sweep runs beside its own.
22
+ *
23
+ * Measured in CREDITS — the unit the customer was sold and the meter shows —
24
+ * from this plugin's line of the month's spend (the billed `estCostUsd`)
25
+ * through the one conversion, so this alert and the Billing meter cannot
26
+ * disagree about how far into the band the org is. A plan with no band
27
+ * resolves `null` and reads as a limit of 0, which the sweep skips the way it
28
+ * skips Free's zero-quota dimensions.
29
+ *
30
+ * Approaching the band is the sweep's generic warning; REACHING it says what
31
+ * happens next, which differs by plan and by the org's own controls — metered
32
+ * at a rate unless a stop is set, or stopped until next month with nothing
33
+ * ever billed — and telling a Free org to "set a monthly cap" on a charge it
34
+ * can never incur would be the surprise bill in reverse.
35
+ */ export async function aiQuotaChecks(context) {
36
+ var _ref;
37
+ var _context_spend_lines_find;
38
+ const org = context.org;
39
+ const bandCredits = resolveAssistCreditBudget(org);
40
+ const spentUsd = (_ref = (_context_spend_lines_find = context.spend.lines.find((line)=>line.id === AI_ASSIST_SPEND_LINE_ID)) == null ? void 0 : _context_spend_lines_find.usd) != null ? _ref : 0;
41
+ // Whether crossing the band produces an invoice line or a stop. The same
42
+ // predicate the reservation refuses on, so the words and the behavior
43
+ // cannot drift: a band that refuses — the org's own switch, or a plan with
44
+ // no rate — is a wall, and everything else is sold past.
45
+ const bandIsWall = assistBandRefuses(org);
46
+ // Through the same resolver `assistBandRefuses` asks, so the alert's rate
47
+ // and its wall/sold verdict cannot disagree about one org. Read straight off
48
+ // `PLAN_PRICING` they did: Starter's listed rate is null and the add-on's is
49
+ // added by the resolver, so a Starter workspace with the add-on was told
50
+ // "sold past the band" at "$0.00 per 1,000" in the same sentence (AGL-3014).
51
+ const rateUsdPer1k = resolveAssistOverageRateUsdPer1k(org);
52
+ const rate = `$${(rateUsdPer1k != null ? rateUsdPer1k : 0).toFixed(2)} per 1,000`;
53
+ return [
54
+ {
55
+ key: AI_CREDITS_QUOTA_KEY,
56
+ label: 'AI assist credits',
57
+ noun: 'AI assist credits',
58
+ used: assistCreditsFromUsd(spentUsd),
59
+ limit: bandCredits != null ? bandCredits : 0,
60
+ cadence: 'monthly',
61
+ outcome: bandIsWall ? 'stops' : 'bills',
62
+ reachedTitle: bandIsWall ? "You've used your included AI assist credits" : "You've used your included AI assist credits — extra credits " + 'are now billed',
63
+ reached: bandIsWall ? 'AI assist stops here until next month — nothing past the band ' + 'is ever billed. Upgrade in Billing to add credits' + (resolveAssistHardCap(org) && rateUsdPer1k !== null ? `, or turn off "${ASSIST_HARD_CAP_CONTROL_LABEL}" ` + `under ${ASSIST_HARD_CAP_CONTROL_LOCATION} to keep ` + `going at your plan’s rate.` : '.') : 'The assistant keeps answering, and the extra credits are ' + `metered on your monthly invoice at ${rate} — unless you set a ` + `stop under ${ASSIST_HARD_CAP_CONTROL_LOCATION}: stop at the ` + 'included band, or stop once this month’s overage reaches a ' + 'figure you choose.',
64
+ approach: bandIsWall ? 'Nothing changes and nothing is charged — at the included band, ' + 'AI assist stops until next month, and nothing past it is ever ' + 'billed.' : 'Nothing is charged yet — past the included band, extra credits ' + `are metered on your monthly invoice at ${rate} unless you set a ` + `stop under ${ASSIST_HARD_CAP_CONTROL_LOCATION}.`
65
+ }
66
+ ];
67
+ }
68
+
69
+ //# sourceMappingURL=ai-quota-checks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/ai-quota-checks.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n ASSIST_HARD_CAP_CONTROL_LABEL,\n ASSIST_HARD_CAP_CONTROL_LOCATION,\n assistBandRefuses,\n assistCreditsFromUsd,\n resolveAssistCreditBudget,\n resolveAssistHardCap,\n resolveAssistOverageRateUsdPer1k,\n} from './assist-credits'\nimport type {\n UsageQuotaCheck,\n UsageQuotaContext,\n} from '@aglyn/aglyn/plugin-manager/usage-alert-contributors'\nimport { AI_ASSIST_SPEND_LINE_ID } from '../usage-axes'\n\n/** The guard key the credits band is warned under. */\nexport const AI_CREDITS_QUOTA_KEY = 'assistCredits'\n\n/**\n * The AI credits band the WORKSPACE is warned about (AGL-2898), as a quota\n * check the usage-alerts sweep runs beside its own.\n *\n * Measured in CREDITS — the unit the customer was sold and the meter shows —\n * from this plugin's line of the month's spend (the billed `estCostUsd`)\n * through the one conversion, so this alert and the Billing meter cannot\n * disagree about how far into the band the org is. A plan with no band\n * resolves `null` and reads as a limit of 0, which the sweep skips the way it\n * skips Free's zero-quota dimensions.\n *\n * Approaching the band is the sweep's generic warning; REACHING it says what\n * happens next, which differs by plan and by the org's own controls — metered\n * at a rate unless a stop is set, or stopped until next month with nothing\n * ever billed — and telling a Free org to \"set a monthly cap\" on a charge it\n * can never incur would be the surprise bill in reverse.\n */\nexport async function aiQuotaChecks(\n context: UsageQuotaContext,\n): Promise<readonly UsageQuotaCheck[]> {\n const org = context.org as never\n const bandCredits = resolveAssistCreditBudget(org)\n const spentUsd =\n context.spend.lines.find((line) => line.id === AI_ASSIST_SPEND_LINE_ID)\n ?.usd ?? 0\n // Whether crossing the band produces an invoice line or a stop. The same\n // predicate the reservation refuses on, so the words and the behavior\n // cannot drift: a band that refuses — the org's own switch, or a plan with\n // no rate — is a wall, and everything else is sold past.\n const bandIsWall = assistBandRefuses(org)\n // Through the same resolver `assistBandRefuses` asks, so the alert's rate\n // and its wall/sold verdict cannot disagree about one org. Read straight off\n // `PLAN_PRICING` they did: Starter's listed rate is null and the add-on's is\n // added by the resolver, so a Starter workspace with the add-on was told\n // \"sold past the band\" at \"$0.00 per 1,000\" in the same sentence (AGL-3014).\n const rateUsdPer1k = resolveAssistOverageRateUsdPer1k(org)\n const rate = `$${(rateUsdPer1k ?? 0).toFixed(2)} per 1,000`\n return [\n {\n key: AI_CREDITS_QUOTA_KEY,\n label: 'AI assist credits',\n noun: 'AI assist credits',\n used: assistCreditsFromUsd(spentUsd),\n limit: bandCredits ?? 0,\n cadence: 'monthly',\n outcome: bandIsWall ? 'stops' : 'bills',\n reachedTitle: bandIsWall\n ? \"You've used your included AI assist credits\"\n : \"You've used your included AI assist credits — extra credits \" +\n 'are now billed',\n reached: bandIsWall\n ? 'AI assist stops here until next month — nothing past the band ' +\n 'is ever billed. Upgrade in Billing to add credits' +\n (resolveAssistHardCap(org) && rateUsdPer1k !== null\n ? `, or turn off \"${ASSIST_HARD_CAP_CONTROL_LABEL}\" ` +\n `under ${ASSIST_HARD_CAP_CONTROL_LOCATION} to keep ` +\n `going at your plan’s rate.`\n : '.')\n : 'The assistant keeps answering, and the extra credits are ' +\n `metered on your monthly invoice at ${rate} — unless you set a ` +\n `stop under ${ASSIST_HARD_CAP_CONTROL_LOCATION}: stop at the ` +\n 'included band, or stop once this month’s overage reaches a ' +\n 'figure you choose.',\n approach: bandIsWall\n ? 'Nothing changes and nothing is charged — at the included band, ' +\n 'AI assist stops until next month, and nothing past it is ever ' +\n 'billed.'\n : 'Nothing is charged yet — past the included band, extra credits ' +\n `are metered on your monthly invoice at ${rate} unless you set a ` +\n `stop under ${ASSIST_HARD_CAP_CONTROL_LOCATION}.`,\n },\n ]\n}\n"],"names":["ASSIST_HARD_CAP_CONTROL_LABEL","ASSIST_HARD_CAP_CONTROL_LOCATION","assistBandRefuses","assistCreditsFromUsd","resolveAssistCreditBudget","resolveAssistHardCap","resolveAssistOverageRateUsdPer1k","AI_ASSIST_SPEND_LINE_ID","AI_CREDITS_QUOTA_KEY","aiQuotaChecks","context","org","bandCredits","spentUsd","spend","lines","find","line","id","usd","bandIsWall","rateUsdPer1k","rate","toFixed","key","label","noun","used","limit","cadence","outcome","reachedTitle","reached","approach"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,6BAA6B,EAC7BC,gCAAgC,EAChCC,iBAAiB,EACjBC,oBAAoB,EACpBC,yBAAyB,EACzBC,oBAAoB,EACpBC,gCAAgC,QAC3B,sBAAkB;AAKzB,SAASC,uBAAuB,QAAQ,mBAAe;AAEvD,oDAAoD,GACpD,OAAO,MAAMC,uBAAuB,gBAAe;AAEnD;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,eAAeC,cACpBC,OAA0B;;QAKxBA;IAHF,MAAMC,MAAMD,QAAQC,GAAG;IACvB,MAAMC,cAAcR,0BAA0BO;IAC9C,MAAME,oBACJH,4BAAAA,QAAQI,KAAK,CAACC,KAAK,CAACC,IAAI,CAAC,CAACC,OAASA,KAAKC,EAAE,KAAKX,6CAA/CG,0BACIS,GAAG,mBAAI;IACb,yEAAyE;IACzE,sEAAsE;IACtE,2EAA2E;IAC3E,yDAAyD;IACzD,MAAMC,aAAalB,kBAAkBS;IACrC,0EAA0E;IAC1E,6EAA6E;IAC7E,6EAA6E;IAC7E,yEAAyE;IACzE,6EAA6E;IAC7E,MAAMU,eAAef,iCAAiCK;IACtD,MAAMW,OAAO,CAAC,CAAC,EAAE,CAACD,uBAAAA,eAAgB,GAAGE,OAAO,CAAC,GAAG,UAAU,CAAC;IAC3D,OAAO;QACL;YACEC,KAAKhB;YACLiB,OAAO;YACPC,MAAM;YACNC,MAAMxB,qBAAqBU;YAC3Be,KAAK,EAAEhB,sBAAAA,cAAe;YACtBiB,SAAS;YACTC,SAASV,aAAa,UAAU;YAChCW,cAAcX,aACV,gDACA,iEACA;YACJY,SAASZ,aACL,mEACA,sDACCf,CAAAA,qBAAqBM,QAAQU,iBAAiB,OAC3C,CAAC,eAAe,EAAErB,8BAA8B,EAAE,CAAC,GACnD,CAAC,MAAM,EAAEC,iCAAiC,SAAS,CAAC,GACpD,CAAC,0BAA0B,CAAC,GAC5B,GAAE,IACN,8DACA,CAAC,mCAAmC,EAAEqB,KAAK,oBAAoB,CAAC,GAChE,CAAC,WAAW,EAAErB,iCAAiC,cAAc,CAAC,GAC9D,gEACA;YACJgC,UAAUb,aACN,oEACA,mEACA,YACA,oEACA,CAAC,uCAAuC,EAAEE,KAAK,kBAAkB,CAAC,GAClE,CAAC,WAAW,EAAErB,iCAAiC,CAAC,CAAC;QACvD;KACD;AACH"}
@@ -13,11 +13,12 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ import { resolveAssistCreditBudget } from "@aglyn/aglyn/app-utils/assist-credits";
16
+ */ import { resolveAssistCreditBudget } from "./assist-credits.js";
17
17
  import { aiAddonName, hasAiAddon, isUncappedPlanComp } from "@aglyn/aglyn/app-utils/plan-entitlements";
18
18
  import { PLATFORM_BRAND_NAME } from "@aglyn/aglyn/app-utils/platform-brand";
19
- import { assistCeilingBreach, assistCogsAlertThresholdUsd, assistMarginBreach, assistMarginMultiple } from "@aglyn/aglyn/app-utils/usage-budget";
19
+ import { assistCeilingBreach, assistCogsAlertThresholdUsd, assistMarginBreach, assistMarginMultiple } from "./assist-spend-guards.js";
20
20
  import { assistBackstopCeilingUsd } from "./assist-ceiling.js";
21
+ import { AI_ASSIST_SPEND_LINE_ID } from "../usage-axes.js";
21
22
  /**
22
23
  * The AI plugin's staff alerts on provider spend (AGL-2984), evaluated for
23
24
  * every org by the usage-alerts sweep through its usage alert contributor
@@ -56,23 +57,33 @@ import { assistBackstopCeilingUsd } from "./assist-ceiling.js";
56
57
  await alertOnMarginGuard(context);
57
58
  await alertOnHardCeiling(context);
58
59
  }
60
+ /**
61
+ * The month's assist spend in dollars: this plugin's own line of the spend
62
+ * the sweep built (`usageAxes` → `spendLines`), or nothing when the line is
63
+ * absent.
64
+ */ function assistSpendUsd({ spend }) {
65
+ var _ref;
66
+ var _spend_lines_find;
67
+ return (_ref = (_spend_lines_find = spend.lines.find((line)=>line.id === AI_ASSIST_SPEND_LINE_ID)) == null ? void 0 : _spend_lines_find.usd) != null ? _ref : 0;
68
+ }
59
69
  /** How both alerts name the org: its slug, or its id when it has none. */ function orgName({ orgSlug, orgId }) {
60
70
  return orgSlug != null ? orgSlug : orgId;
61
71
  }
62
72
  async function alertOnMarginGuard(context) {
63
- const { org, month, spend } = context;
73
+ const { org, month } = context;
74
+ const assistUsd = assistSpendUsd(context);
64
75
  const thresholdUsd = assistCogsAlertThresholdUsd(process.env.ASSIST_ORG_MONTHLY_COGS_ALERT_USD);
65
76
  // The guard records the whole multiple announced, which is 0 for a zero or
66
77
  // non-finite threshold — a reading the breach predicate never agrees to.
67
- const multiple = assistMarginMultiple(spend.assistUsd, thresholdUsd);
78
+ const multiple = assistMarginMultiple(assistUsd, thresholdUsd);
68
79
  const due = assistMarginBreach({
69
- assistUsd: spend.assistUsd,
80
+ assistUsd,
70
81
  thresholdUsd,
71
82
  guard: context.guards[AI_MARGIN_GUARD_KEY],
72
83
  month
73
84
  });
74
85
  if (!due || !context.recordAlert(AI_MARGIN_GUARD_KEY, multiple)) return;
75
- const title = `Assist token spend is $${spend.assistUsd.toFixed(2)} for one org this month`;
86
+ const title = `Assist token spend is $${assistUsd.toFixed(2)} for one org this month`;
76
87
  // The add-on state and the band: the same dollar figure is a finding on an
77
88
  // org paying for the add-on and an incident on one that is not, and the
78
89
  // reader should not have to open the console to know which.
@@ -83,18 +94,19 @@ async function alertOnMarginGuard(context) {
83
94
  quota: AI_MARGIN_GUARD_KEY,
84
95
  threshold: multiple,
85
96
  title,
86
- body: `${orgName(context)} has run about ` + `$${spend.assistUsd.toFixed(2)} of ${PLATFORM_BRAND_NAME} Assist ` + `tokens in ${month}, past the $${thresholdUsd.toFixed(0)} review ` + 'threshold. Assist is a plan entitlement with no per-token ' + 'price, so this is margin, not revenue. ' + `${addonClause}, with ${bandClause}.`,
97
+ body: `${orgName(context)} has run about ` + `$${assistUsd.toFixed(2)} of ${PLATFORM_BRAND_NAME} Assist ` + `tokens in ${month}, past the $${thresholdUsd.toFixed(0)} review ` + 'threshold. Assist is a plan entitlement with no per-token ' + 'price, so this is margin, not revenue. ' + `${addonClause}, with ${bandClause}.`,
87
98
  link: STAFF_ORGS_LINK,
88
99
  emailContext: 'assist-margin'
89
100
  });
90
101
  }
91
102
  async function alertOnHardCeiling(context) {
92
- const { org, month, spend } = context;
103
+ const { org, month } = context;
104
+ const assistUsd = assistSpendUsd(context);
93
105
  // The same composition the reservation refuses on, for this org, so the
94
106
  // figure announced and the figure enforced cannot drift apart.
95
107
  const ceilingUsd = assistBackstopCeilingUsd(org);
96
108
  const due = assistCeilingBreach({
97
- assistUsd: spend.assistUsd,
109
+ assistUsd,
98
110
  ceilingUsd,
99
111
  guard: context.guards[AI_CEILING_GUARD_KEY],
100
112
  month
@@ -105,7 +117,7 @@ async function alertOnHardCeiling(context) {
105
117
  quota: AI_CEILING_GUARD_KEY,
106
118
  threshold: 1,
107
119
  title: `Assist is REFUSING ${orgName(context)} — the ` + `${ceiling} monthly spend ceiling is crossed`,
108
- body: `${orgName(context)} has run about ` + `$${spend.assistUsd.toFixed(2)} of ${PLATFORM_BRAND_NAME} Assist ` + `tokens in ${month}, past the ` + `${ceiling} ceiling. Every further ` + 'Assist request from this organization is refused until the month ' + 'rolls over — the assistant is not degraded or slowed, it is off. ' + 'The customer keeps whatever messages their plan has left, and the ' + 'refusal says so in its own words.',
120
+ body: `${orgName(context)} has run about ` + `$${assistUsd.toFixed(2)} of ${PLATFORM_BRAND_NAME} Assist ` + `tokens in ${month}, past the ` + `${ceiling} ceiling. Every further ` + 'Assist request from this organization is refused until the month ' + 'rolls over — the assistant is not degraded or slowed, it is off. ' + 'The customer keeps whatever messages their plan has left, and the ' + 'refusal says so in its own words.',
109
121
  link: STAFF_ORGS_LINK,
110
122
  emailContext: 'assist-ceiling'
111
123
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/ai-usage-alerts.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { resolveAssistCreditBudget } from '@aglyn/aglyn/app-utils/assist-credits'\nimport {\n aiAddonName,\n hasAiAddon,\n isUncappedPlanComp,\n} from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { PLATFORM_BRAND_NAME } from '@aglyn/aglyn/app-utils/platform-brand'\nimport {\n assistCeilingBreach,\n assistCogsAlertThresholdUsd,\n assistMarginBreach,\n assistMarginMultiple,\n} from '@aglyn/aglyn/app-utils/usage-budget'\nimport type { UsageAlertContext } from '@aglyn/aglyn/plugin-manager/usage-alert-contributors'\nimport { assistBackstopCeilingUsd } from './assist-ceiling'\n\n/**\n * The AI plugin's staff alerts on provider spend (AGL-2984), evaluated for\n * every org by the usage-alerts sweep through its usage alert contributor\n * seam. Two alerts, in this order, each deduped under its own guard key:\n *\n * - THE MARGIN GUARD warns. The meter caps an entitled org by MESSAGE count,\n * and a month of long, cache-cold exchanges on a large model is a\n * three-figure provider bill against a subscription that did not move. One\n * org's spend crossing the review threshold tells staff, and every further\n * whole multiple of the threshold tells them again, so an escalating cost\n * never goes quiet because it already spoke.\n * - THE HARD CEILING reports a stop. Past the monthly spend ceiling the\n * reservation refuses every assist request from the org, so its assistant\n * has stopped answering, and staff are told so in those words. It cannot\n * ride the margin guard: that one speaks in whole multiples of its\n * threshold, so an org climbing from the threshold to the ceiling is still\n * at 1x and says nothing. Announced once a month, because crossing is a\n * state rather than an escalating sum.\n *\n * The ceiling is the one THIS org's reservations refuse at —\n * `assistBackstopCeilingUsd` — not the repo default for everyone. That\n * default binds only a workspace with no band of its own; a band sold past,\n * a band that is a wall, and an uncapped staff comp are each measured\n * against something else, and announcing a $40 stop the reservation never\n * makes would send staff after an assistant that is still answering.\n *\n * Both are STAFF alerts. The org is not charged for this spend and has done\n * nothing wrong, so mailing the customer about our cost would be alarming and\n * meaningless. Both read the spend figure the sweep built, record through the\n * sweep's guard and send through the sweep's senders, so the first-sweep\n * seeding and the run's report apply to them exactly as to core's alerts.\n */\n\n/** The margin guard's key: the whole multiples announced this month. */\nexport const AI_MARGIN_GUARD_KEY = 'assistCogs'\n\n/** The hard ceiling's key: announced once for the month. */\nexport const AI_CEILING_GUARD_KEY = 'assistCeiling'\n\n/** Both alerts send staff to the org list. */\nconst STAFF_ORGS_LINK = '/admin/orgs'\n\n/** Evaluates one org: the margin guard, then the hard ceiling. */\nexport async function evaluateAiUsageAlerts(\n context: UsageAlertContext,\n): Promise<void> {\n await alertOnMarginGuard(context)\n await alertOnHardCeiling(context)\n}\n\n/** How both alerts name the org: its slug, or its id when it has none. */\nfunction orgName({ orgSlug, orgId }: UsageAlertContext): string {\n return orgSlug ?? orgId\n}\n\nasync function alertOnMarginGuard(context: UsageAlertContext): Promise<void> {\n const { org, month, spend } = context\n const thresholdUsd = assistCogsAlertThresholdUsd(\n process.env.ASSIST_ORG_MONTHLY_COGS_ALERT_USD,\n )\n // The guard records the whole multiple announced, which is 0 for a zero or\n // non-finite threshold — a reading the breach predicate never agrees to.\n const multiple = assistMarginMultiple(spend.assistUsd, thresholdUsd)\n const due = assistMarginBreach({\n assistUsd: spend.assistUsd,\n thresholdUsd,\n guard: context.guards[AI_MARGIN_GUARD_KEY],\n month,\n })\n if (!due || !context.recordAlert(AI_MARGIN_GUARD_KEY, multiple)) return\n\n const title = `Assist token spend is $${spend.assistUsd.toFixed(2)} for one org this month`\n // The add-on state and the band: the same dollar figure is a finding on an\n // org paying for the add-on and an incident on one that is not, and the\n // reader should not have to open the console to know which.\n const addonClause = hasAiAddon(org as never)\n ? `The ${aiAddonName()} add-on is on`\n : `The ${aiAddonName()} add-on is off`\n const bandCredits = resolveAssistCreditBudget(org as never)\n const bandClause =\n bandCredits !== null\n ? `an AI credit band of ${bandCredits.toLocaleString()}`\n : isUncappedPlanComp(org as never)\n ? 'no AI credit band (an uncapped staff comp)'\n : 'no AI credit band'\n await context.alertStaff({\n quota: AI_MARGIN_GUARD_KEY,\n threshold: multiple,\n title,\n body:\n `${orgName(context)} has run about ` +\n `$${spend.assistUsd.toFixed(2)} of ${PLATFORM_BRAND_NAME} Assist ` +\n `tokens in ${month}, past the $${thresholdUsd.toFixed(0)} review ` +\n 'threshold. Assist is a plan entitlement with no per-token ' +\n 'price, so this is margin, not revenue. ' +\n `${addonClause}, with ${bandClause}.`,\n link: STAFF_ORGS_LINK,\n emailContext: 'assist-margin',\n })\n}\n\nasync function alertOnHardCeiling(context: UsageAlertContext): Promise<void> {\n const { org, month, spend } = context\n // The same composition the reservation refuses on, for this org, so the\n // figure announced and the figure enforced cannot drift apart.\n const ceilingUsd = assistBackstopCeilingUsd(org as never)\n const due = assistCeilingBreach({\n assistUsd: spend.assistUsd,\n ceilingUsd,\n guard: context.guards[AI_CEILING_GUARD_KEY],\n month,\n })\n if (!due || !context.recordAlert(AI_CEILING_GUARD_KEY, 1)) return\n\n const ceiling = `$${Number(ceilingUsd).toFixed(0)}`\n await context.alertStaff({\n quota: AI_CEILING_GUARD_KEY,\n threshold: 1,\n title:\n `Assist is REFUSING ${orgName(context)} — the ` +\n `${ceiling} monthly spend ceiling is crossed`,\n body:\n `${orgName(context)} has run about ` +\n `$${spend.assistUsd.toFixed(2)} of ${PLATFORM_BRAND_NAME} Assist ` +\n `tokens in ${month}, past the ` +\n `${ceiling} ceiling. Every further ` +\n 'Assist request from this organization is refused until the month ' +\n 'rolls over — the assistant is not degraded or slowed, it is off. ' +\n 'The customer keeps whatever messages their plan has left, and the ' +\n 'refusal says so in its own words.',\n link: STAFF_ORGS_LINK,\n emailContext: 'assist-ceiling',\n })\n}\n"],"names":["resolveAssistCreditBudget","aiAddonName","hasAiAddon","isUncappedPlanComp","PLATFORM_BRAND_NAME","assistCeilingBreach","assistCogsAlertThresholdUsd","assistMarginBreach","assistMarginMultiple","assistBackstopCeilingUsd","AI_MARGIN_GUARD_KEY","AI_CEILING_GUARD_KEY","STAFF_ORGS_LINK","evaluateAiUsageAlerts","context","alertOnMarginGuard","alertOnHardCeiling","orgName","orgSlug","orgId","org","month","spend","thresholdUsd","process","env","ASSIST_ORG_MONTHLY_COGS_ALERT_USD","multiple","assistUsd","due","guard","guards","recordAlert","title","toFixed","addonClause","bandCredits","bandClause","toLocaleString","alertStaff","quota","threshold","body","link","emailContext","ceilingUsd","ceiling","Number"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/ai-usage-alerts.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { resolveAssistCreditBudget } from './assist-credits'\nimport {\n aiAddonName,\n hasAiAddon,\n isUncappedPlanComp,\n} from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { PLATFORM_BRAND_NAME } from '@aglyn/aglyn/app-utils/platform-brand'\nimport {\n assistCeilingBreach,\n assistCogsAlertThresholdUsd,\n assistMarginBreach,\n assistMarginMultiple,\n} from './assist-spend-guards'\nimport type { UsageAlertContext } from '@aglyn/aglyn/plugin-manager/usage-alert-contributors'\nimport { assistBackstopCeilingUsd } from './assist-ceiling'\nimport { AI_ASSIST_SPEND_LINE_ID } from '../usage-axes'\n\n/**\n * The AI plugin's staff alerts on provider spend (AGL-2984), evaluated for\n * every org by the usage-alerts sweep through its usage alert contributor\n * seam. Two alerts, in this order, each deduped under its own guard key:\n *\n * - THE MARGIN GUARD warns. The meter caps an entitled org by MESSAGE count,\n * and a month of long, cache-cold exchanges on a large model is a\n * three-figure provider bill against a subscription that did not move. One\n * org's spend crossing the review threshold tells staff, and every further\n * whole multiple of the threshold tells them again, so an escalating cost\n * never goes quiet because it already spoke.\n * - THE HARD CEILING reports a stop. Past the monthly spend ceiling the\n * reservation refuses every assist request from the org, so its assistant\n * has stopped answering, and staff are told so in those words. It cannot\n * ride the margin guard: that one speaks in whole multiples of its\n * threshold, so an org climbing from the threshold to the ceiling is still\n * at 1x and says nothing. Announced once a month, because crossing is a\n * state rather than an escalating sum.\n *\n * The ceiling is the one THIS org's reservations refuse at —\n * `assistBackstopCeilingUsd` — not the repo default for everyone. That\n * default binds only a workspace with no band of its own; a band sold past,\n * a band that is a wall, and an uncapped staff comp are each measured\n * against something else, and announcing a $40 stop the reservation never\n * makes would send staff after an assistant that is still answering.\n *\n * Both are STAFF alerts. The org is not charged for this spend and has done\n * nothing wrong, so mailing the customer about our cost would be alarming and\n * meaningless. Both read the spend figure the sweep built, record through the\n * sweep's guard and send through the sweep's senders, so the first-sweep\n * seeding and the run's report apply to them exactly as to core's alerts.\n */\n\n/** The margin guard's key: the whole multiples announced this month. */\nexport const AI_MARGIN_GUARD_KEY = 'assistCogs'\n\n/** The hard ceiling's key: announced once for the month. */\nexport const AI_CEILING_GUARD_KEY = 'assistCeiling'\n\n/** Both alerts send staff to the org list. */\nconst STAFF_ORGS_LINK = '/admin/orgs'\n\n/** Evaluates one org: the margin guard, then the hard ceiling. */\nexport async function evaluateAiUsageAlerts(\n context: UsageAlertContext,\n): Promise<void> {\n await alertOnMarginGuard(context)\n await alertOnHardCeiling(context)\n}\n\n/**\n * The month's assist spend in dollars: this plugin's own line of the spend\n * the sweep built (`usageAxes` → `spendLines`), or nothing when the line is\n * absent.\n */\nfunction assistSpendUsd({ spend }: UsageAlertContext): number {\n return spend.lines.find((line) => line.id === AI_ASSIST_SPEND_LINE_ID)?.usd ?? 0\n}\n\n/** How both alerts name the org: its slug, or its id when it has none. */\nfunction orgName({ orgSlug, orgId }: UsageAlertContext): string {\n return orgSlug ?? orgId\n}\n\nasync function alertOnMarginGuard(context: UsageAlertContext): Promise<void> {\n const { org, month } = context\n const assistUsd = assistSpendUsd(context)\n const thresholdUsd = assistCogsAlertThresholdUsd(\n process.env.ASSIST_ORG_MONTHLY_COGS_ALERT_USD,\n )\n // The guard records the whole multiple announced, which is 0 for a zero or\n // non-finite threshold — a reading the breach predicate never agrees to.\n const multiple = assistMarginMultiple(assistUsd, thresholdUsd)\n const due = assistMarginBreach({\n assistUsd,\n thresholdUsd,\n guard: context.guards[AI_MARGIN_GUARD_KEY],\n month,\n })\n if (!due || !context.recordAlert(AI_MARGIN_GUARD_KEY, multiple)) return\n\n const title = `Assist token spend is $${assistUsd.toFixed(2)} for one org this month`\n // The add-on state and the band: the same dollar figure is a finding on an\n // org paying for the add-on and an incident on one that is not, and the\n // reader should not have to open the console to know which.\n const addonClause = hasAiAddon(org as never)\n ? `The ${aiAddonName()} add-on is on`\n : `The ${aiAddonName()} add-on is off`\n const bandCredits = resolveAssistCreditBudget(org as never)\n const bandClause =\n bandCredits !== null\n ? `an AI credit band of ${bandCredits.toLocaleString()}`\n : isUncappedPlanComp(org as never)\n ? 'no AI credit band (an uncapped staff comp)'\n : 'no AI credit band'\n await context.alertStaff({\n quota: AI_MARGIN_GUARD_KEY,\n threshold: multiple,\n title,\n body:\n `${orgName(context)} has run about ` +\n `$${assistUsd.toFixed(2)} of ${PLATFORM_BRAND_NAME} Assist ` +\n `tokens in ${month}, past the $${thresholdUsd.toFixed(0)} review ` +\n 'threshold. Assist is a plan entitlement with no per-token ' +\n 'price, so this is margin, not revenue. ' +\n `${addonClause}, with ${bandClause}.`,\n link: STAFF_ORGS_LINK,\n emailContext: 'assist-margin',\n })\n}\n\nasync function alertOnHardCeiling(context: UsageAlertContext): Promise<void> {\n const { org, month } = context\n const assistUsd = assistSpendUsd(context)\n // The same composition the reservation refuses on, for this org, so the\n // figure announced and the figure enforced cannot drift apart.\n const ceilingUsd = assistBackstopCeilingUsd(org as never)\n const due = assistCeilingBreach({\n assistUsd,\n ceilingUsd,\n guard: context.guards[AI_CEILING_GUARD_KEY],\n month,\n })\n if (!due || !context.recordAlert(AI_CEILING_GUARD_KEY, 1)) return\n\n const ceiling = `$${Number(ceilingUsd).toFixed(0)}`\n await context.alertStaff({\n quota: AI_CEILING_GUARD_KEY,\n threshold: 1,\n title:\n `Assist is REFUSING ${orgName(context)} — the ` +\n `${ceiling} monthly spend ceiling is crossed`,\n body:\n `${orgName(context)} has run about ` +\n `$${assistUsd.toFixed(2)} of ${PLATFORM_BRAND_NAME} Assist ` +\n `tokens in ${month}, past the ` +\n `${ceiling} ceiling. Every further ` +\n 'Assist request from this organization is refused until the month ' +\n 'rolls over — the assistant is not degraded or slowed, it is off. ' +\n 'The customer keeps whatever messages their plan has left, and the ' +\n 'refusal says so in its own words.',\n link: STAFF_ORGS_LINK,\n emailContext: 'assist-ceiling',\n })\n}\n"],"names":["resolveAssistCreditBudget","aiAddonName","hasAiAddon","isUncappedPlanComp","PLATFORM_BRAND_NAME","assistCeilingBreach","assistCogsAlertThresholdUsd","assistMarginBreach","assistMarginMultiple","assistBackstopCeilingUsd","AI_ASSIST_SPEND_LINE_ID","AI_MARGIN_GUARD_KEY","AI_CEILING_GUARD_KEY","STAFF_ORGS_LINK","evaluateAiUsageAlerts","context","alertOnMarginGuard","alertOnHardCeiling","assistSpendUsd","spend","lines","find","line","id","usd","orgName","orgSlug","orgId","org","month","assistUsd","thresholdUsd","process","env","ASSIST_ORG_MONTHLY_COGS_ALERT_USD","multiple","due","guard","guards","recordAlert","title","toFixed","addonClause","bandCredits","bandClause","toLocaleString","alertStaff","quota","threshold","body","link","emailContext","ceilingUsd","ceiling","Number"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,yBAAyB,QAAQ,sBAAkB;AAC5D,SACEC,WAAW,EACXC,UAAU,EACVC,kBAAkB,QACb,2CAA0C;AACjD,SAASC,mBAAmB,QAAQ,wCAAuC;AAC3E,SACEC,mBAAmB,EACnBC,2BAA2B,EAC3BC,kBAAkB,EAClBC,oBAAoB,QACf,2BAAuB;AAE9B,SAASC,wBAAwB,QAAQ,sBAAkB;AAC3D,SAASC,uBAAuB,QAAQ,mBAAe;AAEvD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+BC,GAED,sEAAsE,GACtE,OAAO,MAAMC,sBAAsB,aAAY;AAE/C,0DAA0D,GAC1D,OAAO,MAAMC,uBAAuB,gBAAe;AAEnD,4CAA4C,GAC5C,MAAMC,kBAAkB;AAExB,gEAAgE,GAChE,OAAO,eAAeC,sBACpBC,OAA0B;IAE1B,MAAMC,mBAAmBD;IACzB,MAAME,mBAAmBF;AAC3B;AAEA;;;;CAIC,GACD,SAASG,eAAe,EAAEC,KAAK,EAAqB;;QAC3CA;IAAP,gBAAOA,oBAAAA,MAAMC,KAAK,CAACC,IAAI,CAAC,CAACC,OAASA,KAAKC,EAAE,KAAKb,6CAAvCS,kBAAiEK,GAAG,mBAAI;AACjF;AAEA,wEAAwE,GACxE,SAASC,QAAQ,EAAEC,OAAO,EAAEC,KAAK,EAAqB;IACpD,OAAOD,kBAAAA,UAAWC;AACpB;AAEA,eAAeX,mBAAmBD,OAA0B;IAC1D,MAAM,EAAEa,GAAG,EAAEC,KAAK,EAAE,GAAGd;IACvB,MAAMe,YAAYZ,eAAeH;IACjC,MAAMgB,eAAezB,4BACnB0B,QAAQC,GAAG,CAACC,iCAAiC;IAE/C,2EAA2E;IAC3E,yEAAyE;IACzE,MAAMC,WAAW3B,qBAAqBsB,WAAWC;IACjD,MAAMK,MAAM7B,mBAAmB;QAC7BuB;QACAC;QACAM,OAAOtB,QAAQuB,MAAM,CAAC3B,oBAAoB;QAC1CkB;IACF;IACA,IAAI,CAACO,OAAO,CAACrB,QAAQwB,WAAW,CAAC5B,qBAAqBwB,WAAW;IAEjE,MAAMK,QAAQ,CAAC,uBAAuB,EAAEV,UAAUW,OAAO,CAAC,GAAG,uBAAuB,CAAC;IACrF,2EAA2E;IAC3E,wEAAwE;IACxE,4DAA4D;IAC5D,MAAMC,cAAcxC,WAAW0B,OAC3B,CAAC,IAAI,EAAE3B,cAAc,aAAa,CAAC,GACnC,CAAC,IAAI,EAAEA,cAAc,cAAc,CAAC;IACxC,MAAM0C,cAAc3C,0BAA0B4B;IAC9C,MAAMgB,aACJD,gBAAgB,OACZ,CAAC,qBAAqB,EAAEA,YAAYE,cAAc,IAAI,GACtD1C,mBAAmByB,OACjB,+CACA;IACR,MAAMb,QAAQ+B,UAAU,CAAC;QACvBC,OAAOpC;QACPqC,WAAWb;QACXK;QACAS,MACE,GAAGxB,QAAQV,SAAS,eAAe,CAAC,GACpC,CAAC,CAAC,EAAEe,UAAUW,OAAO,CAAC,GAAG,IAAI,EAAErC,oBAAoB,QAAQ,CAAC,GAC5D,CAAC,UAAU,EAAEyB,MAAM,YAAY,EAAEE,aAAaU,OAAO,CAAC,GAAG,QAAQ,CAAC,GAClE,+DACA,4CACA,GAAGC,YAAY,OAAO,EAAEE,WAAW,CAAC,CAAC;QACvCM,MAAMrC;QACNsC,cAAc;IAChB;AACF;AAEA,eAAelC,mBAAmBF,OAA0B;IAC1D,MAAM,EAAEa,GAAG,EAAEC,KAAK,EAAE,GAAGd;IACvB,MAAMe,YAAYZ,eAAeH;IACjC,wEAAwE;IACxE,+DAA+D;IAC/D,MAAMqC,aAAa3C,yBAAyBmB;IAC5C,MAAMQ,MAAM/B,oBAAoB;QAC9ByB;QACAsB;QACAf,OAAOtB,QAAQuB,MAAM,CAAC1B,qBAAqB;QAC3CiB;IACF;IACA,IAAI,CAACO,OAAO,CAACrB,QAAQwB,WAAW,CAAC3B,sBAAsB,IAAI;IAE3D,MAAMyC,UAAU,CAAC,CAAC,EAAEC,OAAOF,YAAYX,OAAO,CAAC,IAAI;IACnD,MAAM1B,QAAQ+B,UAAU,CAAC;QACvBC,OAAOnC;QACPoC,WAAW;QACXR,OACE,CAAC,mBAAmB,EAAEf,QAAQV,SAAS,OAAO,CAAC,GAC/C,GAAGsC,QAAQ,iCAAiC,CAAC;QAC/CJ,MACE,GAAGxB,QAAQV,SAAS,eAAe,CAAC,GACpC,CAAC,CAAC,EAAEe,UAAUW,OAAO,CAAC,GAAG,IAAI,EAAErC,oBAAoB,QAAQ,CAAC,GAC5D,CAAC,UAAU,EAAEyB,MAAM,WAAW,CAAC,GAC/B,GAAGwB,QAAQ,wBAAwB,CAAC,GACpC,sEACA,sEACA,uEACA;QACFH,MAAMrC;QACNsC,cAAc;IAChB;AACF"}
@@ -20,7 +20,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
20
20
  import { FieldValue } from "firebase-admin/firestore";
21
21
  import { AI_USAGE_BY_USER_COLLECTION, AI_USAGE_BY_USER_MONTHS_COLLECTION, AI_USAGE_BY_USER_RETENTION_MONTHS, aiUsageByUserExpiry, aiUsageByUserMonthFrom, aiUsageMonthKeys, aiUsageShare } from "../model/ai-usage-by-user.js";
22
22
  import { AI_USAGE_TOKENS_FIELD } from "../model/ai-tokens.js";
23
- import { assistCreditsFromUsd } from "@aglyn/aglyn/app-utils/assist-credits";
23
+ import { assistCreditsFromUsd } from "./assist-credits.js";
24
24
  /**
25
25
  * A request's four token counts as increments, under the names every rollup
26
26
  * keeps them by (`model/ai-tokens.ts`).
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/ai-usage-by-user.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// Straight from the SDK, not the admin lib, for the reason `assist-usage.ts`\n// gives at its head: the statics need no app, and the admin lib would drag the\n// default-app initialization into every unit test that touches a counter.\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n AI_USAGE_BY_USER_COLLECTION,\n AI_USAGE_BY_USER_MONTHS_COLLECTION,\n AI_USAGE_BY_USER_RETENTION_MONTHS,\n aiUsageByUserExpiry,\n aiUsageByUserMonthFrom,\n aiUsageMonthKeys,\n aiUsageShare,\n type AiUsageByUserMonth,\n type AiUsageKind,\n} from '../model/ai-usage-by-user'\nimport { AI_USAGE_TOKENS_FIELD } from '../model/ai-tokens'\nimport type { AiUsage } from '../providers/contract'\nimport { assistCreditsFromUsd } from '@aglyn/aglyn/app-utils/assist-credits'\n\n/**\n * PER-USER AI USAGE (AGL-2928): the writer and the readers.\n *\n * One document per person per month, under the org they spent in:\n *\n * orgs/{orgId}/aiUsageByUser/{uid}/months/{YYYY-MM}\n * { uid, month, credits, estCostUsd, requests, refusals,\n * tokens: { input, cached, cacheWrite, output },\n * byKind: { kind: credits }, byHost: { hostId: credits },\n * updatedAt, expiresAt }\n *\n * ## Written in the org rollup's own batch\n *\n * `recordUserAiUsage` takes the BATCH the org rollup is being written into\n * and adds this document to it, so the two cannot disagree: a batch commits\n * whole or not at all, and there is no path on which the workspace's month\n * moves and the person's does not. Both assist entrypoints hand it the batch\n * from inside `recordAssistCost` / `recordAssistExchange`, and the job\n * runner meters through the first of those, so a generation step lands here\n * with the job's kind and creator.\n *\n * ## No prose, no PII past the uid\n *\n * The exchange's verbatim half already expires at 180 days for the reason\n * `assist-usage.ts` gives; this rollup is integers keyed by an id, and it\n * is kept longer (`AI_USAGE_BY_USER_RETENTION_MONTHS`) because a workspace\n * admin's question is month-over-month. It is still a document ABOUT a\n * person, so it is readable only by the person and by members holding\n * `billing.view` or `org.auditLog` (the rules, and every route that serves\n * it), and an account erasure sweeps it (`eraseUserAiUsage`).\n */\n\n/** What one metered request attributes to a person. */\nexport interface RecordUserAiUsageInput {\n /** The person who asked. No uid, no attribution — the write is skipped. */\n uid: string | null | undefined\n /** The billing month the org rollup is being written into. */\n month: string\n /** The request's measured provider spend, as the org rollup counts it. */\n estCostUsd: number\n hostId: string | null | undefined\n kind: AiUsageKind\n /** The request's four token counts (AGL-2937), added to the month's `tokens`. */\n usage?: AiUsage\n}\n\n/**\n * A request's four token counts as increments, under the names every rollup\n * keeps them by (`model/ai-tokens.ts`).\n */\nexport function aiTokenIncrements(usage: AiUsage): Record<string, FirebaseFirestore.FieldValue> {\n const increment = FieldValue.increment\n return {\n input: increment(usage.inputTokens),\n cached: increment(usage.cacheReadTokens),\n cacheWrite: increment(usage.cacheWriteTokens),\n output: increment(usage.outputTokens),\n }\n}\n\nexport function userAiUsageMonthRef(\n orgRef: FirebaseFirestore.DocumentReference,\n uid: string,\n month: string,\n): FirebaseFirestore.DocumentReference {\n return orgRef\n .collection(AI_USAGE_BY_USER_COLLECTION)\n .doc(uid)\n .collection(AI_USAGE_BY_USER_MONTHS_COLLECTION)\n .doc(month)\n}\n\n/**\n * Fold one request into a person's month, on the caller's batch.\n *\n * Returns whether anything was queued: a request with no uid (a meter that\n * could not say who asked) attributes to nobody rather than to a placeholder\n * id that would rank on every table. `set(…, { merge: true })`, never\n * `update()`: the document does not exist before a person's first request\n * of the month, and a merge into a map field adds the key rather than\n * replacing the map.\n */\nexport function recordUserAiUsage(\n batch: FirebaseFirestore.WriteBatch,\n orgRef: FirebaseFirestore.DocumentReference,\n input: RecordUserAiUsageInput,\n): boolean {\n const uid = String(input.uid ?? '').trim()\n if (!uid) return false\n const estCostUsd =\n Number.isFinite(input.estCostUsd) && input.estCostUsd > 0\n ? input.estCostUsd\n : 0\n const credits = assistCreditsFromUsd(estCostUsd)\n const increment = FieldValue.increment\n const hostId = String(input.hostId ?? '').trim()\n batch.set(\n userAiUsageMonthRef(orgRef, uid, input.month),\n {\n uid,\n month: input.month,\n credits: increment(credits),\n estCostUsd: increment(estCostUsd),\n requests: increment(1),\n ...(input.usage ? { [AI_USAGE_TOKENS_FIELD]: aiTokenIncrements(input.usage) } : {}),\n byKind: { [input.kind]: increment(credits) },\n ...(hostId ? { byHost: { [hostId]: increment(credits) } } : {}),\n updatedAt: FieldValue.serverTimestamp(),\n // The retention window, as a Timestamp the TTL policy can act on.\n expiresAt: aiUsageByUserExpiry(input.month),\n },\n { merge: true },\n )\n return true\n}\n\n/**\n * The slice of a reservation a refusal is counted from: whether the gate\n * admitted the request, and the billing month it decided in. Structural, so\n * every door can hand over the reservation it already holds.\n */\nexport interface UserAiRefusalSource {\n allowed: boolean\n monthKey: string\n}\n\n/**\n * Count a refused reservation against the person who asked — the per-person\n * twin of `recordAssistRefusal`, and shaped the same way: fire-and-forget,\n * off the refusal's await path, swallowing every error, because a refusal\n * is the gate's cheap exit and a counter must not make it dearer than the\n * thing it counts.\n *\n * Takes the whole reservation rather than a month so every door calls it\n * the same way, once, right after reserving: an admitted reservation records\n * nothing, so the call needs no branch of its own and cannot drift from the\n * door's refusal branch. The month is the reservation's, not the clock's,\n * because that is the month the org's own refusal was counted in.\n */\nexport function recordUserAiRefusal(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string | null | undefined,\n reservation: UserAiRefusalSource,\n): void {\n if (reservation.allowed) return\n const subject = String(uid ?? '').trim()\n if (!subject) return\n try {\n const ref = userAiUsageMonthRef(\n firestore.collection('orgs').doc(orgId),\n subject,\n reservation.monthKey,\n )\n void Promise.resolve(\n ref.set(\n {\n uid: subject,\n month: reservation.monthKey,\n refusals: FieldValue.increment(1),\n updatedAt: FieldValue.serverTimestamp(),\n expiresAt: aiUsageByUserExpiry(reservation.monthKey),\n },\n { merge: true },\n ),\n ).catch(() => undefined)\n } catch {\n // A double without `set`, a ref that could not be built — the refusal\n // has already been decided and this must not change that.\n }\n}\n\n/** One roster row's month, with the person the roster names. */\nexport interface OrgAiUsageByUserRow extends AiUsageByUserMonth {\n /** The display name, else the email, else the uid — never blank. */\n name: string\n email: string | null\n role: string | null\n /** This person's share of the org's measured spend, in `[0, 1]`. */\n share: number\n}\n\nexport interface OrgAiUsageByUserMonth {\n month: string\n /** The org rollup's spend for the month — the denominator of every share. */\n orgEstCostUsd: number\n orgCredits: number\n /** Every current member with a document for the month, dearest first. */\n rows: OrgAiUsageByUserRow[]\n}\n\n/** How many references one `getAll` carries. */\nconst GET_ALL_CHUNK = 100\n\n/**\n * The month for every current member of the org, dearest first.\n *\n * Reads the ROSTER and then each member's month document by path, rather\n * than a collection-group query over `months`: the roster is the join the\n * table needs anyway (a uid is not a name), it is bounded by the seats the\n * plan sells, and a path read rides no index that has to be deployed before\n * a workspace can open its Usage page. A person who has left the org is not\n * listed — the roster is the set of people the reader may see — and their\n * documents expire on the retention window.\n */\nexport async function readOrgAiUsageByUser(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n month: string,\n): Promise<OrgAiUsageByUserMonth> {\n const orgRef = firestore.collection('orgs').doc(orgId)\n const [roster, rollup] = await Promise.all([\n orgRef.collection('members').get(),\n orgRef.collection('assistUsage').doc(month).get(),\n ])\n const orgEstCostUsd = Number(rollup.get('estCostUsd') ?? 0)\n const denominator =\n Number.isFinite(orgEstCostUsd) && orgEstCostUsd > 0 ? orgEstCostUsd : 0\n\n const members = roster.docs.map((doc) => ({\n uid: doc.id,\n email: typeof doc.get('email') === 'string' ? String(doc.get('email')) : null,\n displayName:\n typeof doc.get('displayName') === 'string' && String(doc.get('displayName')).trim()\n ? String(doc.get('displayName')).trim()\n : null,\n role: typeof doc.get('role') === 'string' ? String(doc.get('role')) : null,\n }))\n\n const rows: OrgAiUsageByUserRow[] = []\n for (let start = 0; start < members.length; start += GET_ALL_CHUNK) {\n const chunk = members.slice(start, start + GET_ALL_CHUNK)\n const snapshots = await firestore.getAll(\n ...chunk.map((member) => userAiUsageMonthRef(orgRef, member.uid, month)),\n )\n snapshots.forEach((snapshot, index) => {\n if (!snapshot.exists) return\n const member = chunk[index]\n const usage = aiUsageByUserMonthFrom(snapshot.data(), member.uid, month)\n rows.push({\n ...usage,\n name: member.displayName ?? member.email ?? member.uid,\n email: member.email,\n role: member.role,\n share: aiUsageShare(usage.estCostUsd, denominator),\n })\n })\n }\n rows.sort(\n (a, b) =>\n b.credits - a.credits ||\n b.requests - a.requests ||\n a.name.localeCompare(b.name),\n )\n return {\n month,\n orgEstCostUsd: denominator,\n orgCredits: assistCreditsFromUsd(denominator),\n rows,\n }\n}\n\n/**\n * One person's months in one org, newest first — the member card and the\n * staff page. The document id IS the month key, so no field every writer must\n * remember carries the order.\n *\n * ## Asked for by id, never ordered (AGL-3143 §13)\n *\n * This read `.orderBy(documentId(), 'desc').limit(n)`, which looks\n * like the obvious way to say \"newest first\" and **500s in production**:\n * Firestore indexes `__name__` ASCENDING automatically and descending NOT at\n * all, so the query answers `FAILED_PRECONDITION: the query requires an\n * index`. `GET /api/ai/usage?uid=…` returned 500 on every call and the member\n * card read \"Could not read this member's AI usage — a failed read, not zero\n * usage\", while the org-wide table beside it, which orders on a real field,\n * was fine.\n *\n * ⛔ The fix is deliberately NOT the index the error offers. A promotion ships\n * Vercel and nothing else — it deploys no rules and no indexes — so a fix that\n * needs one would land green, ship, and stay broken until someone remembered a\n * separate deploy. There is nothing to remember here.\n *\n * The whole key space is knowable without a query: `aiUsageMonthKeys` is the\n * retention window and the TTL reaps everything older, so the thirteen ids are\n * asked for directly and the ones that exist come back in the order they were\n * asked. Bounded at thirteen point reads, and it cannot return a month outside\n * retention, which is what the door promises its callers.\n */\nexport async function readUserAiUsageMonths(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string,\n limit: number = AI_USAGE_BY_USER_RETENTION_MONTHS,\n now: Date = new Date(),\n): Promise<AiUsageByUserMonth[]> {\n const months = firestore\n .collection('orgs')\n .doc(orgId)\n .collection(AI_USAGE_BY_USER_COLLECTION)\n .doc(uid)\n .collection(AI_USAGE_BY_USER_MONTHS_COLLECTION)\n const wanted = aiUsageMonthKeys(now)\n const snapshots = await firestore.getAll(...wanted.map((month) => months.doc(month)))\n return snapshots\n .filter((doc) => doc.exists)\n .slice(0, Math.max(1, limit))\n .map((doc) => aiUsageByUserMonthFrom(doc.data(), uid, doc.id))\n}\n\nexport interface EraseUserAiUsageResult {\n /** Orgs whose `aiUsageByUser/{uid}` subtree was deleted by path. */\n orgs: number\n /**\n * Month documents found by the collection-group sweep and deleted — the\n * ones in orgs the person had already left. `null` when the sweep could\n * not run (its index is not deployed), which is not zero.\n */\n sweptMonths: number | null\n}\n\n/**\n * Remove a person's usage documents on account erasure.\n *\n * Two passes, because the documents live under the ORG and are keyed by the\n * uid: `recursiveDelete(users/{uid})` cannot see them, and neither can a\n * walk of current memberships find the months left behind in an org the\n * person was removed from. So every org the erasure knows of is deleted by\n * path first — no index, cannot fail for want of one — and then a\n * collection-group query on `uid` catches the rest. The second pass is\n * reported as `null` rather than thrown when it cannot run: the erasure has\n * a legal clock, and the by-path pass has already removed everything a\n * current membership could reach.\n */\nexport async function eraseUserAiUsage(\n firestore: FirebaseFirestore.Firestore,\n uid: string,\n orgIds: readonly string[],\n): Promise<EraseUserAiUsageResult> {\n let orgs = 0\n for (const orgId of orgIds) {\n await firestore.recursiveDelete(\n firestore\n .collection('orgs')\n .doc(orgId)\n .collection(AI_USAGE_BY_USER_COLLECTION)\n .doc(uid),\n )\n orgs += 1\n }\n let sweptMonths: number | null = null\n try {\n const stray = await firestore\n .collectionGroup(AI_USAGE_BY_USER_MONTHS_COLLECTION)\n .where('uid', '==', uid)\n .get()\n sweptMonths = 0\n for (let start = 0; start < stray.docs.length; start += GET_ALL_CHUNK) {\n const batch = firestore.batch()\n for (const doc of stray.docs.slice(start, start + GET_ALL_CHUNK)) {\n batch.delete(doc.ref)\n sweptMonths += 1\n }\n await batch.commit()\n }\n } catch (error) {\n console.error(`eraseUserAiUsage: collection-group sweep failed for ${uid}`, error)\n }\n return { orgs, sweptMonths }\n}\n"],"names":["FieldValue","AI_USAGE_BY_USER_COLLECTION","AI_USAGE_BY_USER_MONTHS_COLLECTION","AI_USAGE_BY_USER_RETENTION_MONTHS","aiUsageByUserExpiry","aiUsageByUserMonthFrom","aiUsageMonthKeys","aiUsageShare","AI_USAGE_TOKENS_FIELD","assistCreditsFromUsd","aiTokenIncrements","usage","increment","input","inputTokens","cached","cacheReadTokens","cacheWrite","cacheWriteTokens","output","outputTokens","userAiUsageMonthRef","orgRef","uid","month","collection","doc","recordUserAiUsage","batch","String","trim","estCostUsd","Number","isFinite","credits","hostId","set","requests","byKind","kind","byHost","updatedAt","serverTimestamp","expiresAt","merge","recordUserAiRefusal","firestore","orgId","reservation","allowed","subject","ref","monthKey","Promise","resolve","refusals","catch","undefined","GET_ALL_CHUNK","readOrgAiUsageByUser","rollup","roster","all","get","orgEstCostUsd","denominator","members","docs","map","id","email","displayName","role","rows","start","length","chunk","slice","snapshots","getAll","member","forEach","snapshot","index","exists","data","push","name","share","sort","a","b","localeCompare","orgCredits","readUserAiUsageMonths","limit","now","Date","months","wanted","filter","Math","max","eraseUserAiUsage","orgIds","orgs","recursiveDelete","sweptMonths","stray","collectionGroup","where","delete","commit","error","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/ai-usage-by-user.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// Straight from the SDK, not the admin lib, for the reason `assist-usage.ts`\n// gives at its head: the statics need no app, and the admin lib would drag the\n// default-app initialization into every unit test that touches a counter.\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n AI_USAGE_BY_USER_COLLECTION,\n AI_USAGE_BY_USER_MONTHS_COLLECTION,\n AI_USAGE_BY_USER_RETENTION_MONTHS,\n aiUsageByUserExpiry,\n aiUsageByUserMonthFrom,\n aiUsageMonthKeys,\n aiUsageShare,\n type AiUsageByUserMonth,\n type AiUsageKind,\n} from '../model/ai-usage-by-user'\nimport { AI_USAGE_TOKENS_FIELD } from '../model/ai-tokens'\nimport type { AiUsage } from '../providers/contract'\nimport { assistCreditsFromUsd } from './assist-credits'\n\n/**\n * PER-USER AI USAGE (AGL-2928): the writer and the readers.\n *\n * One document per person per month, under the org they spent in:\n *\n * orgs/{orgId}/aiUsageByUser/{uid}/months/{YYYY-MM}\n * { uid, month, credits, estCostUsd, requests, refusals,\n * tokens: { input, cached, cacheWrite, output },\n * byKind: { kind: credits }, byHost: { hostId: credits },\n * updatedAt, expiresAt }\n *\n * ## Written in the org rollup's own batch\n *\n * `recordUserAiUsage` takes the BATCH the org rollup is being written into\n * and adds this document to it, so the two cannot disagree: a batch commits\n * whole or not at all, and there is no path on which the workspace's month\n * moves and the person's does not. Both assist entrypoints hand it the batch\n * from inside `recordAssistCost` / `recordAssistExchange`, and the job\n * runner meters through the first of those, so a generation step lands here\n * with the job's kind and creator.\n *\n * ## No prose, no PII past the uid\n *\n * The exchange's verbatim half already expires at 180 days for the reason\n * `assist-usage.ts` gives; this rollup is integers keyed by an id, and it\n * is kept longer (`AI_USAGE_BY_USER_RETENTION_MONTHS`) because a workspace\n * admin's question is month-over-month. It is still a document ABOUT a\n * person, so it is readable only by the person and by members holding\n * `billing.view` or `org.auditLog` (the rules, and every route that serves\n * it), and an account erasure sweeps it (`eraseUserAiUsage`).\n */\n\n/** What one metered request attributes to a person. */\nexport interface RecordUserAiUsageInput {\n /** The person who asked. No uid, no attribution — the write is skipped. */\n uid: string | null | undefined\n /** The billing month the org rollup is being written into. */\n month: string\n /** The request's measured provider spend, as the org rollup counts it. */\n estCostUsd: number\n hostId: string | null | undefined\n kind: AiUsageKind\n /** The request's four token counts (AGL-2937), added to the month's `tokens`. */\n usage?: AiUsage\n}\n\n/**\n * A request's four token counts as increments, under the names every rollup\n * keeps them by (`model/ai-tokens.ts`).\n */\nexport function aiTokenIncrements(usage: AiUsage): Record<string, FirebaseFirestore.FieldValue> {\n const increment = FieldValue.increment\n return {\n input: increment(usage.inputTokens),\n cached: increment(usage.cacheReadTokens),\n cacheWrite: increment(usage.cacheWriteTokens),\n output: increment(usage.outputTokens),\n }\n}\n\nexport function userAiUsageMonthRef(\n orgRef: FirebaseFirestore.DocumentReference,\n uid: string,\n month: string,\n): FirebaseFirestore.DocumentReference {\n return orgRef\n .collection(AI_USAGE_BY_USER_COLLECTION)\n .doc(uid)\n .collection(AI_USAGE_BY_USER_MONTHS_COLLECTION)\n .doc(month)\n}\n\n/**\n * Fold one request into a person's month, on the caller's batch.\n *\n * Returns whether anything was queued: a request with no uid (a meter that\n * could not say who asked) attributes to nobody rather than to a placeholder\n * id that would rank on every table. `set(…, { merge: true })`, never\n * `update()`: the document does not exist before a person's first request\n * of the month, and a merge into a map field adds the key rather than\n * replacing the map.\n */\nexport function recordUserAiUsage(\n batch: FirebaseFirestore.WriteBatch,\n orgRef: FirebaseFirestore.DocumentReference,\n input: RecordUserAiUsageInput,\n): boolean {\n const uid = String(input.uid ?? '').trim()\n if (!uid) return false\n const estCostUsd =\n Number.isFinite(input.estCostUsd) && input.estCostUsd > 0\n ? input.estCostUsd\n : 0\n const credits = assistCreditsFromUsd(estCostUsd)\n const increment = FieldValue.increment\n const hostId = String(input.hostId ?? '').trim()\n batch.set(\n userAiUsageMonthRef(orgRef, uid, input.month),\n {\n uid,\n month: input.month,\n credits: increment(credits),\n estCostUsd: increment(estCostUsd),\n requests: increment(1),\n ...(input.usage ? { [AI_USAGE_TOKENS_FIELD]: aiTokenIncrements(input.usage) } : {}),\n byKind: { [input.kind]: increment(credits) },\n ...(hostId ? { byHost: { [hostId]: increment(credits) } } : {}),\n updatedAt: FieldValue.serverTimestamp(),\n // The retention window, as a Timestamp the TTL policy can act on.\n expiresAt: aiUsageByUserExpiry(input.month),\n },\n { merge: true },\n )\n return true\n}\n\n/**\n * The slice of a reservation a refusal is counted from: whether the gate\n * admitted the request, and the billing month it decided in. Structural, so\n * every door can hand over the reservation it already holds.\n */\nexport interface UserAiRefusalSource {\n allowed: boolean\n monthKey: string\n}\n\n/**\n * Count a refused reservation against the person who asked — the per-person\n * twin of `recordAssistRefusal`, and shaped the same way: fire-and-forget,\n * off the refusal's await path, swallowing every error, because a refusal\n * is the gate's cheap exit and a counter must not make it dearer than the\n * thing it counts.\n *\n * Takes the whole reservation rather than a month so every door calls it\n * the same way, once, right after reserving: an admitted reservation records\n * nothing, so the call needs no branch of its own and cannot drift from the\n * door's refusal branch. The month is the reservation's, not the clock's,\n * because that is the month the org's own refusal was counted in.\n */\nexport function recordUserAiRefusal(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string | null | undefined,\n reservation: UserAiRefusalSource,\n): void {\n if (reservation.allowed) return\n const subject = String(uid ?? '').trim()\n if (!subject) return\n try {\n const ref = userAiUsageMonthRef(\n firestore.collection('orgs').doc(orgId),\n subject,\n reservation.monthKey,\n )\n void Promise.resolve(\n ref.set(\n {\n uid: subject,\n month: reservation.monthKey,\n refusals: FieldValue.increment(1),\n updatedAt: FieldValue.serverTimestamp(),\n expiresAt: aiUsageByUserExpiry(reservation.monthKey),\n },\n { merge: true },\n ),\n ).catch(() => undefined)\n } catch {\n // A double without `set`, a ref that could not be built — the refusal\n // has already been decided and this must not change that.\n }\n}\n\n/** One roster row's month, with the person the roster names. */\nexport interface OrgAiUsageByUserRow extends AiUsageByUserMonth {\n /** The display name, else the email, else the uid — never blank. */\n name: string\n email: string | null\n role: string | null\n /** This person's share of the org's measured spend, in `[0, 1]`. */\n share: number\n}\n\nexport interface OrgAiUsageByUserMonth {\n month: string\n /** The org rollup's spend for the month — the denominator of every share. */\n orgEstCostUsd: number\n orgCredits: number\n /** Every current member with a document for the month, dearest first. */\n rows: OrgAiUsageByUserRow[]\n}\n\n/** How many references one `getAll` carries. */\nconst GET_ALL_CHUNK = 100\n\n/**\n * The month for every current member of the org, dearest first.\n *\n * Reads the ROSTER and then each member's month document by path, rather\n * than a collection-group query over `months`: the roster is the join the\n * table needs anyway (a uid is not a name), it is bounded by the seats the\n * plan sells, and a path read rides no index that has to be deployed before\n * a workspace can open its Usage page. A person who has left the org is not\n * listed — the roster is the set of people the reader may see — and their\n * documents expire on the retention window.\n */\nexport async function readOrgAiUsageByUser(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n month: string,\n): Promise<OrgAiUsageByUserMonth> {\n const orgRef = firestore.collection('orgs').doc(orgId)\n const [roster, rollup] = await Promise.all([\n orgRef.collection('members').get(),\n orgRef.collection('assistUsage').doc(month).get(),\n ])\n const orgEstCostUsd = Number(rollup.get('estCostUsd') ?? 0)\n const denominator =\n Number.isFinite(orgEstCostUsd) && orgEstCostUsd > 0 ? orgEstCostUsd : 0\n\n const members = roster.docs.map((doc) => ({\n uid: doc.id,\n email: typeof doc.get('email') === 'string' ? String(doc.get('email')) : null,\n displayName:\n typeof doc.get('displayName') === 'string' && String(doc.get('displayName')).trim()\n ? String(doc.get('displayName')).trim()\n : null,\n role: typeof doc.get('role') === 'string' ? String(doc.get('role')) : null,\n }))\n\n const rows: OrgAiUsageByUserRow[] = []\n for (let start = 0; start < members.length; start += GET_ALL_CHUNK) {\n const chunk = members.slice(start, start + GET_ALL_CHUNK)\n const snapshots = await firestore.getAll(\n ...chunk.map((member) => userAiUsageMonthRef(orgRef, member.uid, month)),\n )\n snapshots.forEach((snapshot, index) => {\n if (!snapshot.exists) return\n const member = chunk[index]\n const usage = aiUsageByUserMonthFrom(snapshot.data(), member.uid, month)\n rows.push({\n ...usage,\n name: member.displayName ?? member.email ?? member.uid,\n email: member.email,\n role: member.role,\n share: aiUsageShare(usage.estCostUsd, denominator),\n })\n })\n }\n rows.sort(\n (a, b) =>\n b.credits - a.credits ||\n b.requests - a.requests ||\n a.name.localeCompare(b.name),\n )\n return {\n month,\n orgEstCostUsd: denominator,\n orgCredits: assistCreditsFromUsd(denominator),\n rows,\n }\n}\n\n/**\n * One person's months in one org, newest first — the member card and the\n * staff page. The document id IS the month key, so no field every writer must\n * remember carries the order.\n *\n * ## Asked for by id, never ordered (AGL-3143 §13)\n *\n * This read `.orderBy(documentId(), 'desc').limit(n)`, which looks\n * like the obvious way to say \"newest first\" and **500s in production**:\n * Firestore indexes `__name__` ASCENDING automatically and descending NOT at\n * all, so the query answers `FAILED_PRECONDITION: the query requires an\n * index`. `GET /api/ai/usage?uid=…` returned 500 on every call and the member\n * card read \"Could not read this member's AI usage — a failed read, not zero\n * usage\", while the org-wide table beside it, which orders on a real field,\n * was fine.\n *\n * ⛔ The fix is deliberately NOT the index the error offers. A promotion ships\n * Vercel and nothing else — it deploys no rules and no indexes — so a fix that\n * needs one would land green, ship, and stay broken until someone remembered a\n * separate deploy. There is nothing to remember here.\n *\n * The whole key space is knowable without a query: `aiUsageMonthKeys` is the\n * retention window and the TTL reaps everything older, so the thirteen ids are\n * asked for directly and the ones that exist come back in the order they were\n * asked. Bounded at thirteen point reads, and it cannot return a month outside\n * retention, which is what the door promises its callers.\n */\nexport async function readUserAiUsageMonths(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string,\n limit: number = AI_USAGE_BY_USER_RETENTION_MONTHS,\n now: Date = new Date(),\n): Promise<AiUsageByUserMonth[]> {\n const months = firestore\n .collection('orgs')\n .doc(orgId)\n .collection(AI_USAGE_BY_USER_COLLECTION)\n .doc(uid)\n .collection(AI_USAGE_BY_USER_MONTHS_COLLECTION)\n const wanted = aiUsageMonthKeys(now)\n const snapshots = await firestore.getAll(...wanted.map((month) => months.doc(month)))\n return snapshots\n .filter((doc) => doc.exists)\n .slice(0, Math.max(1, limit))\n .map((doc) => aiUsageByUserMonthFrom(doc.data(), uid, doc.id))\n}\n\nexport interface EraseUserAiUsageResult {\n /** Orgs whose `aiUsageByUser/{uid}` subtree was deleted by path. */\n orgs: number\n /**\n * Month documents found by the collection-group sweep and deleted — the\n * ones in orgs the person had already left. `null` when the sweep could\n * not run (its index is not deployed), which is not zero.\n */\n sweptMonths: number | null\n}\n\n/**\n * Remove a person's usage documents on account erasure.\n *\n * Two passes, because the documents live under the ORG and are keyed by the\n * uid: `recursiveDelete(users/{uid})` cannot see them, and neither can a\n * walk of current memberships find the months left behind in an org the\n * person was removed from. So every org the erasure knows of is deleted by\n * path first — no index, cannot fail for want of one — and then a\n * collection-group query on `uid` catches the rest. The second pass is\n * reported as `null` rather than thrown when it cannot run: the erasure has\n * a legal clock, and the by-path pass has already removed everything a\n * current membership could reach.\n */\nexport async function eraseUserAiUsage(\n firestore: FirebaseFirestore.Firestore,\n uid: string,\n orgIds: readonly string[],\n): Promise<EraseUserAiUsageResult> {\n let orgs = 0\n for (const orgId of orgIds) {\n await firestore.recursiveDelete(\n firestore\n .collection('orgs')\n .doc(orgId)\n .collection(AI_USAGE_BY_USER_COLLECTION)\n .doc(uid),\n )\n orgs += 1\n }\n let sweptMonths: number | null = null\n try {\n const stray = await firestore\n .collectionGroup(AI_USAGE_BY_USER_MONTHS_COLLECTION)\n .where('uid', '==', uid)\n .get()\n sweptMonths = 0\n for (let start = 0; start < stray.docs.length; start += GET_ALL_CHUNK) {\n const batch = firestore.batch()\n for (const doc of stray.docs.slice(start, start + GET_ALL_CHUNK)) {\n batch.delete(doc.ref)\n sweptMonths += 1\n }\n await batch.commit()\n }\n } catch (error) {\n console.error(`eraseUserAiUsage: collection-group sweep failed for ${uid}`, error)\n }\n return { orgs, sweptMonths }\n}\n"],"names":["FieldValue","AI_USAGE_BY_USER_COLLECTION","AI_USAGE_BY_USER_MONTHS_COLLECTION","AI_USAGE_BY_USER_RETENTION_MONTHS","aiUsageByUserExpiry","aiUsageByUserMonthFrom","aiUsageMonthKeys","aiUsageShare","AI_USAGE_TOKENS_FIELD","assistCreditsFromUsd","aiTokenIncrements","usage","increment","input","inputTokens","cached","cacheReadTokens","cacheWrite","cacheWriteTokens","output","outputTokens","userAiUsageMonthRef","orgRef","uid","month","collection","doc","recordUserAiUsage","batch","String","trim","estCostUsd","Number","isFinite","credits","hostId","set","requests","byKind","kind","byHost","updatedAt","serverTimestamp","expiresAt","merge","recordUserAiRefusal","firestore","orgId","reservation","allowed","subject","ref","monthKey","Promise","resolve","refusals","catch","undefined","GET_ALL_CHUNK","readOrgAiUsageByUser","rollup","roster","all","get","orgEstCostUsd","denominator","members","docs","map","id","email","displayName","role","rows","start","length","chunk","slice","snapshots","getAll","member","forEach","snapshot","index","exists","data","push","name","share","sort","a","b","localeCompare","orgCredits","readUserAiUsageMonths","limit","now","Date","months","wanted","filter","Math","max","eraseUserAiUsage","orgIds","orgs","recursiveDelete","sweptMonths","stray","collectionGroup","where","delete","commit","error","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,6EAA6E;AAC7E,+EAA+E;AAC/E,0EAA0E;AAC1E,SAASA,UAAU,QAAQ,2BAA0B;AACrD,SACEC,2BAA2B,EAC3BC,kCAAkC,EAClCC,iCAAiC,EACjCC,mBAAmB,EACnBC,sBAAsB,EACtBC,gBAAgB,EAChBC,YAAY,QAGP,+BAA2B;AAClC,SAASC,qBAAqB,QAAQ,wBAAoB;AAE1D,SAASC,oBAAoB,QAAQ,sBAAkB;AAgDvD;;;CAGC,GACD,OAAO,SAASC,kBAAkBC,KAAc;IAC9C,MAAMC,YAAYZ,WAAWY,SAAS;IACtC,OAAO;QACLC,OAAOD,UAAUD,MAAMG,WAAW;QAClCC,QAAQH,UAAUD,MAAMK,eAAe;QACvCC,YAAYL,UAAUD,MAAMO,gBAAgB;QAC5CC,QAAQP,UAAUD,MAAMS,YAAY;IACtC;AACF;AAEA,OAAO,SAASC,oBACdC,MAA2C,EAC3CC,GAAW,EACXC,KAAa;IAEb,OAAOF,OACJG,UAAU,CAACxB,6BACXyB,GAAG,CAACH,KACJE,UAAU,CAACvB,oCACXwB,GAAG,CAACF;AACT;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASG,kBACdC,KAAmC,EACnCN,MAA2C,EAC3CT,KAA6B;QAEVA,YAQGA;IARtB,MAAMU,MAAMM,QAAOhB,aAAAA,MAAMU,GAAG,YAATV,aAAa,IAAIiB,IAAI;IACxC,IAAI,CAACP,KAAK,OAAO;IACjB,MAAMQ,aACJC,OAAOC,QAAQ,CAACpB,MAAMkB,UAAU,KAAKlB,MAAMkB,UAAU,GAAG,IACpDlB,MAAMkB,UAAU,GAChB;IACN,MAAMG,UAAUzB,qBAAqBsB;IACrC,MAAMnB,YAAYZ,WAAWY,SAAS;IACtC,MAAMuB,SAASN,QAAOhB,gBAAAA,MAAMsB,MAAM,YAAZtB,gBAAgB,IAAIiB,IAAI;IAC9CF,MAAMQ,GAAG,CACPf,oBAAoBC,QAAQC,KAAKV,MAAMW,KAAK,GAC5C;QACED;QACAC,OAAOX,MAAMW,KAAK;QAClBU,SAAStB,UAAUsB;QACnBH,YAAYnB,UAAUmB;QACtBM,UAAUzB,UAAU;OAChBC,MAAMF,KAAK,GAAG;QAAE,CAACH,sBAAsB,EAAEE,kBAAkBG,MAAMF,KAAK;IAAE,IAAI,CAAC;QACjF2B,QAAQ;YAAE,CAACzB,MAAM0B,IAAI,CAAC,EAAE3B,UAAUsB;QAAS;OACvCC,SAAS;QAAEK,QAAQ;YAAE,CAACL,OAAO,EAAEvB,UAAUsB;QAAS;IAAE,IAAI,CAAC;QAC7DO,WAAWzC,WAAW0C,eAAe;QACrC,kEAAkE;QAClEC,WAAWvC,oBAAoBS,MAAMW,KAAK;QAE5C;QAAEoB,OAAO;IAAK;IAEhB,OAAO;AACT;AAYA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,oBACdC,SAAsC,EACtCC,KAAa,EACbxB,GAA8B,EAC9ByB,WAAgC;IAEhC,IAAIA,YAAYC,OAAO,EAAE;IACzB,MAAMC,UAAUrB,OAAON,cAAAA,MAAO,IAAIO,IAAI;IACtC,IAAI,CAACoB,SAAS;IACd,IAAI;QACF,MAAMC,MAAM9B,oBACVyB,UAAUrB,UAAU,CAAC,QAAQC,GAAG,CAACqB,QACjCG,SACAF,YAAYI,QAAQ;QAEtB,KAAKC,QAAQC,OAAO,CAClBH,IAAIf,GAAG,CACL;YACEb,KAAK2B;YACL1B,OAAOwB,YAAYI,QAAQ;YAC3BG,UAAUvD,WAAWY,SAAS,CAAC;YAC/B6B,WAAWzC,WAAW0C,eAAe;YACrCC,WAAWvC,oBAAoB4C,YAAYI,QAAQ;QACrD,GACA;YAAER,OAAO;QAAK,IAEhBY,KAAK,CAAC,IAAMC;IAChB,EAAE,eAAM;IACN,sEAAsE;IACtE,0DAA0D;IAC5D;AACF;AAqBA,8CAA8C,GAC9C,MAAMC,gBAAgB;AAEtB;;;;;;;;;;CAUC,GACD,OAAO,eAAeC,qBACpBb,SAAsC,EACtCC,KAAa,EACbvB,KAAa;QAOgBoC;IAL7B,MAAMtC,SAASwB,UAAUrB,UAAU,CAAC,QAAQC,GAAG,CAACqB;IAChD,MAAM,CAACc,QAAQD,OAAO,GAAG,MAAMP,QAAQS,GAAG,CAAC;QACzCxC,OAAOG,UAAU,CAAC,WAAWsC,GAAG;QAChCzC,OAAOG,UAAU,CAAC,eAAeC,GAAG,CAACF,OAAOuC,GAAG;KAChD;IACD,MAAMC,gBAAgBhC,QAAO4B,cAAAA,OAAOG,GAAG,CAAC,yBAAXH,cAA4B;IACzD,MAAMK,cACJjC,OAAOC,QAAQ,CAAC+B,kBAAkBA,gBAAgB,IAAIA,gBAAgB;IAExE,MAAME,UAAUL,OAAOM,IAAI,CAACC,GAAG,CAAC,CAAC1C,MAAS,CAAA;YACxCH,KAAKG,IAAI2C,EAAE;YACXC,OAAO,OAAO5C,IAAIqC,GAAG,CAAC,aAAa,WAAWlC,OAAOH,IAAIqC,GAAG,CAAC,YAAY;YACzEQ,aACE,OAAO7C,IAAIqC,GAAG,CAAC,mBAAmB,YAAYlC,OAAOH,IAAIqC,GAAG,CAAC,gBAAgBjC,IAAI,KAC7ED,OAAOH,IAAIqC,GAAG,CAAC,gBAAgBjC,IAAI,KACnC;YACN0C,MAAM,OAAO9C,IAAIqC,GAAG,CAAC,YAAY,WAAWlC,OAAOH,IAAIqC,GAAG,CAAC,WAAW;QACxE,CAAA;IAEA,MAAMU,OAA8B,EAAE;IACtC,IAAK,IAAIC,QAAQ,GAAGA,QAAQR,QAAQS,MAAM,EAAED,SAAShB,cAAe;QAClE,MAAMkB,QAAQV,QAAQW,KAAK,CAACH,OAAOA,QAAQhB;QAC3C,MAAMoB,YAAY,MAAMhC,UAAUiC,MAAM,IACnCH,MAAMR,GAAG,CAAC,CAACY,SAAW3D,oBAAoBC,QAAQ0D,OAAOzD,GAAG,EAAEC;QAEnEsD,UAAUG,OAAO,CAAC,CAACC,UAAUC;gBAMnBH,MAAAA;YALR,IAAI,CAACE,SAASE,MAAM,EAAE;YACtB,MAAMJ,SAASJ,KAAK,CAACO,MAAM;YAC3B,MAAMxE,QAAQN,uBAAuB6E,SAASG,IAAI,IAAIL,OAAOzD,GAAG,EAAEC;YAClEiD,KAAKa,IAAI,CAAC,aACL3E;gBACH4E,IAAI,GAAEP,QAAAA,sBAAAA,OAAOT,WAAW,YAAlBS,sBAAsBA,OAAOV,KAAK,YAAlCU,OAAsCA,OAAOzD,GAAG;gBACtD+C,OAAOU,OAAOV,KAAK;gBACnBE,MAAMQ,OAAOR,IAAI;gBACjBgB,OAAOjF,aAAaI,MAAMoB,UAAU,EAAEkC;;QAE1C;IACF;IACAQ,KAAKgB,IAAI,CACP,CAACC,GAAGC,IACFA,EAAEzD,OAAO,GAAGwD,EAAExD,OAAO,IACrByD,EAAEtD,QAAQ,GAAGqD,EAAErD,QAAQ,IACvBqD,EAAEH,IAAI,CAACK,aAAa,CAACD,EAAEJ,IAAI;IAE/B,OAAO;QACL/D;QACAwC,eAAeC;QACf4B,YAAYpF,qBAAqBwD;QACjCQ;IACF;AACF;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,OAAO,eAAeqB,sBACpBhD,SAAsC,EACtCC,KAAa,EACbxB,GAAW,EACXwE,QAAgB5F,iCAAiC,EACjD6F,MAAY,IAAIC,MAAM;IAEtB,MAAMC,SAASpD,UACZrB,UAAU,CAAC,QACXC,GAAG,CAACqB,OACJtB,UAAU,CAACxB,6BACXyB,GAAG,CAACH,KACJE,UAAU,CAACvB;IACd,MAAMiG,SAAS7F,iBAAiB0F;IAChC,MAAMlB,YAAY,MAAMhC,UAAUiC,MAAM,IAAIoB,OAAO/B,GAAG,CAAC,CAAC5C,QAAU0E,OAAOxE,GAAG,CAACF;IAC7E,OAAOsD,UACJsB,MAAM,CAAC,CAAC1E,MAAQA,IAAI0D,MAAM,EAC1BP,KAAK,CAAC,GAAGwB,KAAKC,GAAG,CAAC,GAAGP,QACrB3B,GAAG,CAAC,CAAC1C,MAAQrB,uBAAuBqB,IAAI2D,IAAI,IAAI9D,KAAKG,IAAI2C,EAAE;AAChE;AAaA;;;;;;;;;;;;CAYC,GACD,OAAO,eAAekC,iBACpBzD,SAAsC,EACtCvB,GAAW,EACXiF,MAAyB;IAEzB,IAAIC,OAAO;IACX,KAAK,MAAM1D,SAASyD,OAAQ;QAC1B,MAAM1D,UAAU4D,eAAe,CAC7B5D,UACGrB,UAAU,CAAC,QACXC,GAAG,CAACqB,OACJtB,UAAU,CAACxB,6BACXyB,GAAG,CAACH;QAETkF,QAAQ;IACV;IACA,IAAIE,cAA6B;IACjC,IAAI;QACF,MAAMC,QAAQ,MAAM9D,UACjB+D,eAAe,CAAC3G,oCAChB4G,KAAK,CAAC,OAAO,MAAMvF,KACnBwC,GAAG;QACN4C,cAAc;QACd,IAAK,IAAIjC,QAAQ,GAAGA,QAAQkC,MAAMzC,IAAI,CAACQ,MAAM,EAAED,SAAShB,cAAe;YACrE,MAAM9B,QAAQkB,UAAUlB,KAAK;YAC7B,KAAK,MAAMF,OAAOkF,MAAMzC,IAAI,CAACU,KAAK,CAACH,OAAOA,QAAQhB,eAAgB;gBAChE9B,MAAMmF,MAAM,CAACrF,IAAIyB,GAAG;gBACpBwD,eAAe;YACjB;YACA,MAAM/E,MAAMoF,MAAM;QACpB;IACF,EAAE,OAAOC,OAAO;QACdC,QAAQD,KAAK,CAAC,CAAC,oDAAoD,EAAE1F,KAAK,EAAE0F;IAC9E;IACA,OAAO;QAAER;QAAME;IAAY;AAC7B"}
@@ -21,8 +21,8 @@ import type { AglynOrgBilling } from '@aglyn/aglyn/foundation/definitions/org-bi
21
21
  *
22
22
  * ## Why the repo default must not bind an org that has a band
23
23
  *
24
- * `ASSIST_ORG_MONTHLY_COGS_LIMIT_DEFAULT_USD` (in `usage-budget.ts`, beside
25
- * the alert that announces the ceiling) is $40, which was sized as a
24
+ * `ASSIST_ORG_MONTHLY_COGS_LIMIT_DEFAULT_USD` (in `assist-spend-guards.ts`,
25
+ * beside the alert that announces the ceiling) is $40, which was sized as a
26
26
  * runaway guard back when every org's assist spend was bounded by a message
27
27
  * cap. It is BELOW what Agency and Enterprise include, so applying it to a
28
28
  * plan band would refuse those workspaces partway through capacity they are
@@ -13,9 +13,9 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ import { assistBandRefuses, resolveAssistBudgetUsd } from "@aglyn/aglyn/app-utils/assist-credits";
16
+ */ import { assistBandRefuses, resolveAssistBudgetUsd } from "./assist-credits.js";
17
17
  import { isUncappedPlanComp } from "@aglyn/aglyn/app-utils/plan-entitlements";
18
- import { assistOperatorCeilingUsd, assistOrgMonthlyCostLimitUsd } from "@aglyn/aglyn/app-utils/usage-budget";
18
+ import { assistOperatorCeilingUsd, assistOrgMonthlyCostLimitUsd } from "./assist-spend-guards.js";
19
19
  /*
20
20
  * THE MONTHLY DOLLAR CEILING an assist reservation is measured against, apart
21
21
  * from the reservation itself: pure arithmetic over the org document and the
@@ -28,8 +28,8 @@ import { assistOperatorCeilingUsd, assistOrgMonthlyCostLimitUsd } from "@aglyn/a
28
28
  *
29
29
  * ## Why the repo default must not bind an org that has a band
30
30
  *
31
- * `ASSIST_ORG_MONTHLY_COGS_LIMIT_DEFAULT_USD` (in `usage-budget.ts`, beside
32
- * the alert that announces the ceiling) is $40, which was sized as a
31
+ * `ASSIST_ORG_MONTHLY_COGS_LIMIT_DEFAULT_USD` (in `assist-spend-guards.ts`,
32
+ * beside the alert that announces the ceiling) is $40, which was sized as a
33
33
  * runaway guard back when every org's assist spend was bounded by a message
34
34
  * cap. It is BELOW what Agency and Enterprise include, so applying it to a
35
35
  * plan band would refuse those workspaces partway through capacity they are
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/assist-ceiling.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n assistBandRefuses,\n resolveAssistBudgetUsd,\n} from '@aglyn/aglyn/app-utils/assist-credits'\nimport { isUncappedPlanComp } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport {\n assistOperatorCeilingUsd,\n assistOrgMonthlyCostLimitUsd,\n} from '@aglyn/aglyn/app-utils/usage-budget'\nimport type { AglynOrgBilling } from '@aglyn/aglyn/foundation/definitions/org-billing.types'\n\n/*\n * THE MONTHLY DOLLAR CEILING an assist reservation is measured against, apart\n * from the reservation itself: pure arithmetic over the org document and the\n * operator's setting, with nothing that writes. The reservation resolves it\n * before its transaction opens, and the staff alert that announces a refusal\n * reads the same composition, so the figure announced is the figure enforced.\n */\n\n/**\n * The ceiling one reservation is actually measured against, given the org's\n * plan band.\n *\n * ## Why the repo default must not bind an org that has a band\n *\n * `ASSIST_ORG_MONTHLY_COGS_LIMIT_DEFAULT_USD` (in `usage-budget.ts`, beside\n * the alert that announces the ceiling) is $40, which was sized as a\n * runaway guard back when every org's assist spend was bounded by a message\n * cap. It is BELOW what Agency and Enterprise include, so applying it to a\n * plan band would refuse those workspaces partway through capacity they are\n * paying for. The rule is not about which tiers happen to clear $40 today: a\n * band that is sold is a product limit, and a default nobody typed is not\n * allowed to undercut one at any size.\n *\n * ## Why an operator's explicit figure still does\n *\n * A self-hoster paying their own provider bill, or an operator responding to\n * an incident, sets `ASSIST_ORG_MONTHLY_COGS_LIMIT_USD` on purpose. The lower\n * of the two wins there, because that is what setting it means.\n *\n * `off` removes the operator's ceiling and does NOT remove a plan band: the\n * word turns off a backstop, and the band is not one.\n *\n * An org with no band (`budgetUsd === null`) is unchanged in every case: it\n * gets exactly the ceiling it got before, default and all. Since AGL-3203\n * every plan row carries a band, so this is an org whose band was overridden\n * to zero rather than a tier that sells none.\n *\n * ## When the band is a line rather than a wall (AGL-2653)\n *\n * `bandRefuses` is `assistBandRefuses(org)`: false on a plan that sells\n * credits past its band unless the org's `assistOverage.hardCap` is on. A\n * band that does not refuse is not a ceiling, so it drops out of the\n * composition and the operator's explicit figure is the only thing left that\n * can bind — the repo default stays off, for the reason above, and `off`\n * leaves nothing. The message cap is still there either way, so a workspace\n * buying overage is bounded by messages a month rather than by nothing; the\n * overage it buys is priced by `report-usage` at the plan's rate.\n *\n * ## An uncapped staff comp (AGL-3049)\n *\n * `uncapped` is `isUncappedPlanComp(org)`. Its band resolves `null` — there\n * is no band — but it is not a workspace that was never sold one, so the\n * repo default must not stand in for a band here any more than it may\n * undercut a sold one: a $40 wall on the workspace staff uncapped is exactly\n * the cap uncapping removes. It composes like a band that does not refuse:\n * the operator's explicit figure binds, because that is an incident\n * decision about every workspace, and nothing else does. The message cap\n * still applies, as it does to every entitled workspace.\n */\nexport function assistMonthlyCeilingUsd(\n budgetUsd: number | null,\n bandRefuses = true,\n uncapped = false,\n): number | null {\n const configured = process.env.ASSIST_ORG_MONTHLY_COGS_LIMIT_USD\n if (uncapped) {\n const operator = assistOperatorCeilingUsd(configured)\n return typeof operator === 'number' ? operator : null\n }\n if (budgetUsd === null) return assistOrgMonthlyCostLimitUsd(configured)\n const operator = assistOperatorCeilingUsd(configured)\n if (!bandRefuses) return typeof operator === 'number' ? operator : null\n return typeof operator === 'number'\n ? Math.min(budgetUsd, operator)\n : budgetUsd\n}\n\n/**\n * The dollar ceiling this org's reservations refuse at that is NOT its own\n * band, or `null` where none applies — the operator backstop as it binds\n * this workspace.\n *\n * A reservation measured against a band that refuses is the workspace's own\n * product limit, refused as `band` and told to the customer by the credits\n * alert. Every other finite ceiling the reservation compares against is a\n * backstop, refused as `budget`, and that is the stop staff are told about.\n * Which one applies is `assistMonthlyCeilingUsd`'s decision, so:\n *\n * - a workspace with no band gets the repo default or the operator's figure;\n * - a band sold past, or an uncapped staff comp (AGL-3049), gets only an\n * operator's explicit figure, and nothing when none is set;\n * - a band that is a wall gets the operator's figure only where it sits\n * below the band, because only then is the operator's figure what refuses.\n */\nexport function assistBackstopCeilingUsd(\n org: Partial<AglynOrgBilling> | null | undefined,\n): number | null {\n const budgetUsd = resolveAssistBudgetUsd(org)\n const bandRefuses = assistBandRefuses(org)\n const ceilingUsd = assistMonthlyCeilingUsd(\n budgetUsd,\n bandRefuses,\n isUncappedPlanComp(org),\n )\n const ceilingIsBand =\n bandRefuses && budgetUsd !== null && ceilingUsd === budgetUsd\n return ceilingIsBand ? null : ceilingUsd\n}\n"],"names":["assistBandRefuses","resolveAssistBudgetUsd","isUncappedPlanComp","assistOperatorCeilingUsd","assistOrgMonthlyCostLimitUsd","assistMonthlyCeilingUsd","budgetUsd","bandRefuses","uncapped","configured","process","env","ASSIST_ORG_MONTHLY_COGS_LIMIT_USD","operator","Math","min","assistBackstopCeilingUsd","org","ceilingUsd","ceilingIsBand"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,iBAAiB,EACjBC,sBAAsB,QACjB,wCAAuC;AAC9C,SAASC,kBAAkB,QAAQ,2CAA0C;AAC7E,SACEC,wBAAwB,EACxBC,4BAA4B,QACvB,sCAAqC;AAG5C;;;;;;CAMC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkDC,GACD,OAAO,SAASC,wBACdC,SAAwB,EACxBC,cAAc,IAAI,EAClBC,WAAW,KAAK;IAEhB,MAAMC,aAAaC,QAAQC,GAAG,CAACC,iCAAiC;IAChE,IAAIJ,UAAU;QACZ,MAAMK,WAAWV,yBAAyBM;QAC1C,OAAO,OAAOI,aAAa,WAAWA,WAAW;IACnD;IACA,IAAIP,cAAc,MAAM,OAAOF,6BAA6BK;IAC5D,MAAMI,WAAWV,yBAAyBM;IAC1C,IAAI,CAACF,aAAa,OAAO,OAAOM,aAAa,WAAWA,WAAW;IACnE,OAAO,OAAOA,aAAa,WACvBC,KAAKC,GAAG,CAACT,WAAWO,YACpBP;AACN;AAEA;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASU,yBACdC,GAAgD;IAEhD,MAAMX,YAAYL,uBAAuBgB;IACzC,MAAMV,cAAcP,kBAAkBiB;IACtC,MAAMC,aAAab,wBACjBC,WACAC,aACAL,mBAAmBe;IAErB,MAAME,gBACJZ,eAAeD,cAAc,QAAQY,eAAeZ;IACtD,OAAOa,gBAAgB,OAAOD;AAChC"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/assist-ceiling.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n assistBandRefuses,\n resolveAssistBudgetUsd,\n} from './assist-credits'\nimport { isUncappedPlanComp } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport {\n assistOperatorCeilingUsd,\n assistOrgMonthlyCostLimitUsd,\n} from './assist-spend-guards'\nimport type { AglynOrgBilling } from '@aglyn/aglyn/foundation/definitions/org-billing.types'\n\n/*\n * THE MONTHLY DOLLAR CEILING an assist reservation is measured against, apart\n * from the reservation itself: pure arithmetic over the org document and the\n * operator's setting, with nothing that writes. The reservation resolves it\n * before its transaction opens, and the staff alert that announces a refusal\n * reads the same composition, so the figure announced is the figure enforced.\n */\n\n/**\n * The ceiling one reservation is actually measured against, given the org's\n * plan band.\n *\n * ## Why the repo default must not bind an org that has a band\n *\n * `ASSIST_ORG_MONTHLY_COGS_LIMIT_DEFAULT_USD` (in `assist-spend-guards.ts`,\n * beside the alert that announces the ceiling) is $40, which was sized as a\n * runaway guard back when every org's assist spend was bounded by a message\n * cap. It is BELOW what Agency and Enterprise include, so applying it to a\n * plan band would refuse those workspaces partway through capacity they are\n * paying for. The rule is not about which tiers happen to clear $40 today: a\n * band that is sold is a product limit, and a default nobody typed is not\n * allowed to undercut one at any size.\n *\n * ## Why an operator's explicit figure still does\n *\n * A self-hoster paying their own provider bill, or an operator responding to\n * an incident, sets `ASSIST_ORG_MONTHLY_COGS_LIMIT_USD` on purpose. The lower\n * of the two wins there, because that is what setting it means.\n *\n * `off` removes the operator's ceiling and does NOT remove a plan band: the\n * word turns off a backstop, and the band is not one.\n *\n * An org with no band (`budgetUsd === null`) is unchanged in every case: it\n * gets exactly the ceiling it got before, default and all. Since AGL-3203\n * every plan row carries a band, so this is an org whose band was overridden\n * to zero rather than a tier that sells none.\n *\n * ## When the band is a line rather than a wall (AGL-2653)\n *\n * `bandRefuses` is `assistBandRefuses(org)`: false on a plan that sells\n * credits past its band unless the org's `assistOverage.hardCap` is on. A\n * band that does not refuse is not a ceiling, so it drops out of the\n * composition and the operator's explicit figure is the only thing left that\n * can bind — the repo default stays off, for the reason above, and `off`\n * leaves nothing. The message cap is still there either way, so a workspace\n * buying overage is bounded by messages a month rather than by nothing; the\n * overage it buys is priced by `report-usage` at the plan's rate.\n *\n * ## An uncapped staff comp (AGL-3049)\n *\n * `uncapped` is `isUncappedPlanComp(org)`. Its band resolves `null` — there\n * is no band — but it is not a workspace that was never sold one, so the\n * repo default must not stand in for a band here any more than it may\n * undercut a sold one: a $40 wall on the workspace staff uncapped is exactly\n * the cap uncapping removes. It composes like a band that does not refuse:\n * the operator's explicit figure binds, because that is an incident\n * decision about every workspace, and nothing else does. The message cap\n * still applies, as it does to every entitled workspace.\n */\nexport function assistMonthlyCeilingUsd(\n budgetUsd: number | null,\n bandRefuses = true,\n uncapped = false,\n): number | null {\n const configured = process.env.ASSIST_ORG_MONTHLY_COGS_LIMIT_USD\n if (uncapped) {\n const operator = assistOperatorCeilingUsd(configured)\n return typeof operator === 'number' ? operator : null\n }\n if (budgetUsd === null) return assistOrgMonthlyCostLimitUsd(configured)\n const operator = assistOperatorCeilingUsd(configured)\n if (!bandRefuses) return typeof operator === 'number' ? operator : null\n return typeof operator === 'number'\n ? Math.min(budgetUsd, operator)\n : budgetUsd\n}\n\n/**\n * The dollar ceiling this org's reservations refuse at that is NOT its own\n * band, or `null` where none applies — the operator backstop as it binds\n * this workspace.\n *\n * A reservation measured against a band that refuses is the workspace's own\n * product limit, refused as `band` and told to the customer by the credits\n * alert. Every other finite ceiling the reservation compares against is a\n * backstop, refused as `budget`, and that is the stop staff are told about.\n * Which one applies is `assistMonthlyCeilingUsd`'s decision, so:\n *\n * - a workspace with no band gets the repo default or the operator's figure;\n * - a band sold past, or an uncapped staff comp (AGL-3049), gets only an\n * operator's explicit figure, and nothing when none is set;\n * - a band that is a wall gets the operator's figure only where it sits\n * below the band, because only then is the operator's figure what refuses.\n */\nexport function assistBackstopCeilingUsd(\n org: Partial<AglynOrgBilling> | null | undefined,\n): number | null {\n const budgetUsd = resolveAssistBudgetUsd(org)\n const bandRefuses = assistBandRefuses(org)\n const ceilingUsd = assistMonthlyCeilingUsd(\n budgetUsd,\n bandRefuses,\n isUncappedPlanComp(org),\n )\n const ceilingIsBand =\n bandRefuses && budgetUsd !== null && ceilingUsd === budgetUsd\n return ceilingIsBand ? null : ceilingUsd\n}\n"],"names":["assistBandRefuses","resolveAssistBudgetUsd","isUncappedPlanComp","assistOperatorCeilingUsd","assistOrgMonthlyCostLimitUsd","assistMonthlyCeilingUsd","budgetUsd","bandRefuses","uncapped","configured","process","env","ASSIST_ORG_MONTHLY_COGS_LIMIT_USD","operator","Math","min","assistBackstopCeilingUsd","org","ceilingUsd","ceilingIsBand"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,iBAAiB,EACjBC,sBAAsB,QACjB,sBAAkB;AACzB,SAASC,kBAAkB,QAAQ,2CAA0C;AAC7E,SACEC,wBAAwB,EACxBC,4BAA4B,QACvB,2BAAuB;AAG9B;;;;;;CAMC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkDC,GACD,OAAO,SAASC,wBACdC,SAAwB,EACxBC,cAAc,IAAI,EAClBC,WAAW,KAAK;IAEhB,MAAMC,aAAaC,QAAQC,GAAG,CAACC,iCAAiC;IAChE,IAAIJ,UAAU;QACZ,MAAMK,WAAWV,yBAAyBM;QAC1C,OAAO,OAAOI,aAAa,WAAWA,WAAW;IACnD;IACA,IAAIP,cAAc,MAAM,OAAOF,6BAA6BK;IAC5D,MAAMI,WAAWV,yBAAyBM;IAC1C,IAAI,CAACF,aAAa,OAAO,OAAOM,aAAa,WAAWA,WAAW;IACnE,OAAO,OAAOA,aAAa,WACvBC,KAAKC,GAAG,CAACT,WAAWO,YACpBP;AACN;AAEA;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASU,yBACdC,GAAgD;IAEhD,MAAMX,YAAYL,uBAAuBgB;IACzC,MAAMV,cAAcP,kBAAkBiB;IACtC,MAAMC,aAAab,wBACjBC,WACAC,aACAL,mBAAmBe;IAErB,MAAME,gBACJZ,eAAeD,cAAc,QAAQY,eAAeZ;IACtD,OAAOa,gBAAgB,OAAOD;AAChC"}