@aglyn/plugins-ai 1.0.0-beta.225 → 1.0.0-beta.227
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.
- package/README.md +2 -1
- package/package.json +17 -17
- package/src/lib/billing/ai-month-meter.js +5 -3
- package/src/lib/billing/ai-month-meter.js.map +1 -1
- package/src/lib/billing/ai-overage-ledger.js +3 -1
- package/src/lib/billing/ai-overage-ledger.js.map +1 -1
- package/src/lib/components/ai-brief-dialog.component.d.ts +6 -2
- package/src/lib/components/ai-brief-dialog.component.js +35 -17
- package/src/lib/components/ai-brief-dialog.component.js.map +1 -1
- package/src/lib/components/ai-describe-automation.component.js +17 -2
- package/src/lib/components/ai-describe-automation.component.js.map +1 -1
- package/src/lib/components/ai-insight-dialog.component.js +24 -2
- package/src/lib/components/ai-insight-dialog.component.js.map +1 -1
- package/src/lib/components/ai-job-events.d.ts +14 -0
- package/src/lib/components/ai-job-events.js +76 -0
- package/src/lib/components/ai-job-events.js.map +1 -0
- package/src/lib/components/ai-job-follow.component.d.ts +49 -0
- package/src/lib/components/ai-job-follow.component.js +210 -0
- package/src/lib/components/ai-job-follow.component.js.map +1 -0
- package/src/lib/components/ai-job-links.d.ts +42 -0
- package/src/lib/components/ai-job-links.js +140 -0
- package/src/lib/components/ai-job-links.js.map +1 -0
- package/src/lib/components/ai-job-plan.component.d.ts +13 -6
- package/src/lib/components/ai-job-plan.component.js +29 -3
- package/src/lib/components/ai-job-plan.component.js.map +1 -1
- package/src/lib/components/ai-job-requests.d.ts +24 -0
- package/src/lib/components/ai-job-requests.js +76 -0
- package/src/lib/components/ai-job-requests.js.map +1 -0
- package/src/lib/components/ai-jobs-indicator.component.d.ts +25 -0
- package/src/lib/components/ai-jobs-indicator.component.js +80 -0
- package/src/lib/components/ai-jobs-indicator.component.js.map +1 -0
- package/src/lib/components/ai-jobs-store.d.ts +81 -0
- package/src/lib/components/ai-jobs-store.js +218 -0
- package/src/lib/components/ai-jobs-store.js.map +1 -0
- package/src/lib/components/ai-page-brief-dialog.component.d.ts +3 -3
- package/src/lib/components/ai-page-brief-dialog.component.js.map +1 -1
- package/src/lib/components/ai-site-build-page.component.d.ts +23 -0
- package/src/lib/components/ai-site-build-page.component.js +377 -0
- package/src/lib/components/ai-site-build-page.component.js.map +1 -0
- package/src/lib/components/ai-site-start-card.component.d.ts +1 -1
- package/src/lib/components/ai-site-start-card.component.js +522 -130
- package/src/lib/components/ai-site-start-card.component.js.map +1 -1
- package/src/lib/components/ai-site-starter-fallback.component.d.ts +24 -0
- package/src/lib/components/ai-site-starter-fallback.component.js +112 -0
- package/src/lib/components/ai-site-starter-fallback.component.js.map +1 -0
- package/src/lib/components/assist-jobs-drawer.component.d.ts +18 -20
- package/src/lib/components/assist-jobs-drawer.component.js +152 -172
- package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
- package/src/lib/components/assist-panel.component.js +62 -6
- package/src/lib/components/assist-panel.component.js.map +1 -1
- package/src/lib/components/staff-ai-credit-return.component.d.ts +55 -0
- package/src/lib/components/staff-ai-credit-return.component.js +346 -0
- package/src/lib/components/staff-ai-credit-return.component.js.map +1 -0
- package/src/lib/components/staff-org-ai-card.component.js +364 -279
- package/src/lib/components/staff-org-ai-card.component.js.map +1 -1
- package/src/lib/components/staff-user-ai-usage-card.component.d.ts +4 -0
- package/src/lib/components/staff-user-ai-usage-card.component.js +83 -28
- package/src/lib/components/staff-user-ai-usage-card.component.js.map +1 -1
- package/src/lib/components/use-ai-job-run.js +5 -18
- package/src/lib/components/use-ai-job-run.js.map +1 -1
- package/src/lib/jobs/ai-job-auto-confirm.d.ts +52 -0
- package/src/lib/jobs/ai-job-auto-confirm.js +62 -0
- package/src/lib/jobs/ai-job-auto-confirm.js.map +1 -0
- package/src/lib/jobs/ai-job-plan-step.d.ts +33 -2
- package/src/lib/jobs/ai-job-plan-step.js +161 -17
- package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
- package/src/lib/jobs/ai-job-site-step.d.ts +7 -0
- package/src/lib/jobs/ai-job-site-step.js +34 -4
- package/src/lib/jobs/ai-job-site-step.js.map +1 -1
- package/src/lib/jobs/ai-job-text-step.d.ts +7 -0
- package/src/lib/jobs/ai-job-text-step.js.map +1 -1
- package/src/lib/jobs/ai-jobs-notify.d.ts +37 -0
- package/src/lib/jobs/ai-jobs-notify.js +51 -0
- package/src/lib/jobs/ai-jobs-notify.js.map +1 -0
- package/src/lib/jobs/ai-jobs.d.ts +31 -4
- package/src/lib/jobs/ai-jobs.js +196 -32
- package/src/lib/jobs/ai-jobs.js.map +1 -1
- package/src/lib/model/ai-job-activity.d.ts +112 -0
- package/src/lib/model/ai-job-activity.js +144 -0
- package/src/lib/model/ai-job-activity.js.map +1 -0
- package/src/lib/model/ai-job-failure-copy.d.ts +69 -0
- package/src/lib/model/ai-job-failure-copy.js +78 -0
- package/src/lib/model/ai-job-failure-copy.js.map +1 -0
- package/src/lib/model/ai-job-notice.d.ts +61 -0
- package/src/lib/model/ai-job-notice.js +112 -0
- package/src/lib/model/ai-job-notice.js.map +1 -0
- package/src/lib/model/ai-jobs.types.d.ts +26 -0
- package/src/lib/model/ai-jobs.types.js +1 -0
- package/src/lib/model/ai-jobs.types.js.map +1 -1
- package/src/lib/model/ai-plan-capabilities.d.ts +7 -0
- package/src/lib/model/ai-plan-capabilities.js.map +1 -1
- package/src/lib/model/ai-site-build-progress.d.ts +45 -0
- package/src/lib/model/ai-site-build-progress.js +76 -0
- package/src/lib/model/ai-site-build-progress.js.map +1 -0
- package/src/lib/model/ai-site-inventory.d.ts +9 -0
- package/src/lib/model/ai-site-inventory.js +7 -2
- package/src/lib/model/ai-site-inventory.js.map +1 -1
- package/src/lib/model/ai-site-job.d.ts +93 -2
- package/src/lib/model/ai-site-job.js +88 -7
- package/src/lib/model/ai-site-job.js.map +1 -1
- package/src/lib/model/ai-site-start.d.ts +3 -1
- package/src/lib/model/ai-site-start.js +6 -4
- package/src/lib/model/ai-site-start.js.map +1 -1
- package/src/lib/model/ai-template-subjects.d.ts +4 -3
- package/src/lib/model/ai-template-subjects.js +5 -4
- package/src/lib/model/ai-template-subjects.js.map +1 -1
- package/src/lib/plan-entitlements.d.ts +3 -3
- package/src/lib/plan-entitlements.js +3 -3
- package/src/lib/plan-entitlements.js.map +1 -1
- package/src/lib/plugin.js +28 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/runtime/ai-abuse-guards.d.ts +9 -6
- package/src/lib/runtime/ai-abuse-guards.js +10 -7
- package/src/lib/runtime/ai-abuse-guards.js.map +1 -1
- package/src/lib/runtime/ai-doctrine-validators.js +31 -4
- package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
- package/src/lib/runtime/ai-gate.d.ts +3 -2
- package/src/lib/runtime/ai-gate.js +3 -2
- package/src/lib/runtime/ai-gate.js.map +1 -1
- package/src/lib/runtime/ai-palette.generated.js +12 -4
- package/src/lib/runtime/ai-palette.generated.js.map +1 -1
- package/src/lib/runtime/site-inventory.d.ts +16 -1
- package/src/lib/runtime/site-inventory.js +43 -3
- package/src/lib/runtime/site-inventory.js.map +1 -1
- package/src/lib/server/ai-admin-credits.d.ts +21 -0
- package/src/lib/server/ai-admin-credits.js +213 -0
- package/src/lib/server/ai-admin-credits.js.map +1 -0
- package/src/lib/server/ai-admin-org.js +12 -5
- package/src/lib/server/ai-admin-org.js.map +1 -1
- package/src/lib/server/ai-admin-signals.js +3 -1
- package/src/lib/server/ai-admin-signals.js.map +1 -1
- package/src/lib/server/ai-admin-user.js +12 -1
- package/src/lib/server/ai-admin-user.js.map +1 -1
- package/src/lib/server/ai-allotments.js +3 -2
- package/src/lib/server/ai-allotments.js.map +1 -1
- package/src/lib/server/ai-jobs-route.js +20 -2
- package/src/lib/server/ai-jobs-route.js.map +1 -1
- package/src/lib/server/assist-chat.js +3 -2
- package/src/lib/server/assist-chat.js.map +1 -1
- package/src/lib/server/billing-credits.js +4 -3
- package/src/lib/server/billing-credits.js.map +1 -1
- package/src/lib/server/billing-overage.js +3 -2
- package/src/lib/server/billing-overage.js.map +1 -1
- package/src/lib/server.js +11 -0
- package/src/lib/server.js.map +1 -1
- package/src/lib/usage/assist-credit-returns-write.d.ts +75 -0
- package/src/lib/usage/assist-credit-returns-write.js +150 -0
- package/src/lib/usage/assist-credit-returns-write.js.map +1 -0
- package/src/lib/usage/assist-credit-returns.d.ts +68 -0
- package/src/lib/usage/assist-credit-returns.js +79 -0
- package/src/lib/usage/assist-credit-returns.js.map +1 -0
- package/src/lib/usage/assist-free-taste.js +6 -3
- package/src/lib/usage/assist-free-taste.js.map +1 -1
- package/src/lib/usage/assist-job-refund.d.ts +100 -0
- package/src/lib/usage/assist-job-refund.js +109 -0
- package/src/lib/usage/assist-job-refund.js.map +1 -0
- package/src/lib/usage/assist-usage.js +5 -2
- package/src/lib/usage/assist-usage.js.map +1 -1
- package/src/lib/usage/staff-org-ai.d.ts +31 -1
- package/src/lib/usage/staff-org-ai.js +19 -2
- package/src/lib/usage/staff-org-ai.js.map +1 -1
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* CREDITS GIVEN BACK (AGL-3595): what a month's meter reads once the credits
|
|
19
|
+
* returned to it are taken off.
|
|
20
|
+
*
|
|
21
|
+
* Two meters count AI credits by the month, both as BILLED dollars in
|
|
22
|
+
* `estCostUsd`:
|
|
23
|
+
*
|
|
24
|
+
* orgs/{orgId}/assistUsage/{YYYY-MM} the WORKSPACE's band
|
|
25
|
+
* users/{uid}/aiUsage/{YYYY-MM} a Free workspace OWNER's account
|
|
26
|
+
* allowance (`assist-free-taste.ts`)
|
|
27
|
+
*
|
|
28
|
+
* A give-back never rewrites `estCostUsd`. It adds to a second field,
|
|
29
|
+
* `returnedUsd`, and records the act under `creditReturns.{key}` on the same
|
|
30
|
+
* document, so the month still says what was spent, and what was handed
|
|
31
|
+
* back, and by whom. Every reader that turns a meter into "credits used" —
|
|
32
|
+
* the reservation, the customer's usage strip and banner, the staff card,
|
|
33
|
+
* the overage the invoice prices — reads the figure through
|
|
34
|
+
* `assistSpendAfterReturnsUsd`, so no surface can show a person a balance the
|
|
35
|
+
* gate would not admit them at.
|
|
36
|
+
*
|
|
37
|
+
* The provider figure is NOT adjusted. `providerCostUsd` is what the month
|
|
38
|
+
* cost Aglyn, and a give-back does not refund the model provider: margins
|
|
39
|
+
* and spend alerts keep reading the real money.
|
|
40
|
+
*/
|
|
41
|
+
/** The field a give-back adds to, in billed USD, beside `estCostUsd`. */
|
|
42
|
+
export declare const ASSIST_RETURNED_USD_FIELD = "returnedUsd";
|
|
43
|
+
/** The map of give-backs on a month document, keyed by idempotency key. */
|
|
44
|
+
export declare const ASSIST_CREDIT_RETURNS_FIELD = "creditReturns";
|
|
45
|
+
/** Which meter a give-back lands on. */
|
|
46
|
+
export type AssistCreditMeter = 'workspace' | 'account';
|
|
47
|
+
/**
|
|
48
|
+
* The month's spend after give-backs, in billed USD — never below zero.
|
|
49
|
+
*
|
|
50
|
+
* Takes the two raw field values rather than a snapshot, so a transaction's
|
|
51
|
+
* snapshot, a plain document and a test double all read the same way:
|
|
52
|
+
* `assistSpendAfterReturnsUsd(snap.get('estCostUsd'), snap.get(ASSIST_RETURNED_USD_FIELD))`.
|
|
53
|
+
*
|
|
54
|
+
* Rounded to 6dp, the precision the meter writes at, so a give-back of
|
|
55
|
+
* exactly what was spent reads as zero rather than as a float's residue —
|
|
56
|
+
* a residue that `assistCreditsFromUsd` would round UP into a credit.
|
|
57
|
+
*/
|
|
58
|
+
export declare function assistSpendAfterReturnsUsd(estCostUsd: unknown, returnedUsd: unknown): number;
|
|
59
|
+
/** The same figure off a month document's data, for readers holding one. */
|
|
60
|
+
export declare function assistMonthSpendUsd(monthDoc: Record<string, unknown> | null | undefined): number;
|
|
61
|
+
/** Credits given back on a month document so far. */
|
|
62
|
+
export declare function assistReturnedCredits(monthDoc: Record<string, unknown> | null | undefined): number;
|
|
63
|
+
/**
|
|
64
|
+
* The most credits a give-back may return to a month: what the meter reads
|
|
65
|
+
* as used after earlier give-backs. A give-back past it would bank credits
|
|
66
|
+
* against next spend, which is a grant, not a compensation.
|
|
67
|
+
*/
|
|
68
|
+
export declare function assistReturnableCredits(monthDoc: Record<string, unknown> | null | undefined): number;
|
|
@@ -0,0 +1,79 @@
|
|
|
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
|
+
*/ // Imported from BOTH graphs — the staff card (client) and every server
|
|
17
|
+
// reader of a credit meter — so it imports no Firebase and no entry barrel.
|
|
18
|
+
import { ASSIST_CREDIT_COST_USD, assistCreditsFromUsd } from "./assist-credits.js";
|
|
19
|
+
/**
|
|
20
|
+
* CREDITS GIVEN BACK (AGL-3595): what a month's meter reads once the credits
|
|
21
|
+
* returned to it are taken off.
|
|
22
|
+
*
|
|
23
|
+
* Two meters count AI credits by the month, both as BILLED dollars in
|
|
24
|
+
* `estCostUsd`:
|
|
25
|
+
*
|
|
26
|
+
* orgs/{orgId}/assistUsage/{YYYY-MM} the WORKSPACE's band
|
|
27
|
+
* users/{uid}/aiUsage/{YYYY-MM} a Free workspace OWNER's account
|
|
28
|
+
* allowance (`assist-free-taste.ts`)
|
|
29
|
+
*
|
|
30
|
+
* A give-back never rewrites `estCostUsd`. It adds to a second field,
|
|
31
|
+
* `returnedUsd`, and records the act under `creditReturns.{key}` on the same
|
|
32
|
+
* document, so the month still says what was spent, and what was handed
|
|
33
|
+
* back, and by whom. Every reader that turns a meter into "credits used" —
|
|
34
|
+
* the reservation, the customer's usage strip and banner, the staff card,
|
|
35
|
+
* the overage the invoice prices — reads the figure through
|
|
36
|
+
* `assistSpendAfterReturnsUsd`, so no surface can show a person a balance the
|
|
37
|
+
* gate would not admit them at.
|
|
38
|
+
*
|
|
39
|
+
* The provider figure is NOT adjusted. `providerCostUsd` is what the month
|
|
40
|
+
* cost Aglyn, and a give-back does not refund the model provider: margins
|
|
41
|
+
* and spend alerts keep reading the real money.
|
|
42
|
+
*/ /** The field a give-back adds to, in billed USD, beside `estCostUsd`. */ export const ASSIST_RETURNED_USD_FIELD = 'returnedUsd';
|
|
43
|
+
/** The map of give-backs on a month document, keyed by idempotency key. */ export const ASSIST_CREDIT_RETURNS_FIELD = 'creditReturns';
|
|
44
|
+
const round6 = (value)=>Math.round(value * 1000000) / 1000000;
|
|
45
|
+
const nonNegative = (value)=>{
|
|
46
|
+
const number = Number(value != null ? value : 0);
|
|
47
|
+
return Number.isFinite(number) && number > 0 ? number : 0;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* The month's spend after give-backs, in billed USD — never below zero.
|
|
51
|
+
*
|
|
52
|
+
* Takes the two raw field values rather than a snapshot, so a transaction's
|
|
53
|
+
* snapshot, a plain document and a test double all read the same way:
|
|
54
|
+
* `assistSpendAfterReturnsUsd(snap.get('estCostUsd'), snap.get(ASSIST_RETURNED_USD_FIELD))`.
|
|
55
|
+
*
|
|
56
|
+
* Rounded to 6dp, the precision the meter writes at, so a give-back of
|
|
57
|
+
* exactly what was spent reads as zero rather than as a float's residue —
|
|
58
|
+
* a residue that `assistCreditsFromUsd` would round UP into a credit.
|
|
59
|
+
*/ export function assistSpendAfterReturnsUsd(estCostUsd, returnedUsd) {
|
|
60
|
+
return Math.max(0, round6(nonNegative(estCostUsd) - nonNegative(returnedUsd)));
|
|
61
|
+
}
|
|
62
|
+
/** The same figure off a month document's data, for readers holding one. */ export function assistMonthSpendUsd(monthDoc) {
|
|
63
|
+
return assistSpendAfterReturnsUsd(monthDoc == null ? void 0 : monthDoc['estCostUsd'], monthDoc == null ? void 0 : monthDoc[ASSIST_RETURNED_USD_FIELD]);
|
|
64
|
+
}
|
|
65
|
+
/** Credits given back on a month document so far. */ export function assistReturnedCredits(monthDoc) {
|
|
66
|
+
// Nearest, not up: a give-back is a whole number of credits, or the whole
|
|
67
|
+
// remainder of a meter, which is never more than one credit's fraction.
|
|
68
|
+
const usd = nonNegative(monthDoc == null ? void 0 : monthDoc[ASSIST_RETURNED_USD_FIELD]);
|
|
69
|
+
return Math.round(usd / ASSIST_CREDIT_COST_USD);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The most credits a give-back may return to a month: what the meter reads
|
|
73
|
+
* as used after earlier give-backs. A give-back past it would bank credits
|
|
74
|
+
* against next spend, which is a grant, not a compensation.
|
|
75
|
+
*/ export function assistReturnableCredits(monthDoc) {
|
|
76
|
+
return assistCreditsFromUsd(assistMonthSpendUsd(monthDoc));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
//# sourceMappingURL=assist-credit-returns.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/assist-credit-returns.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// Imported from BOTH graphs — the staff card (client) and every server\n// reader of a credit meter — so it imports no Firebase and no entry barrel.\nimport { ASSIST_CREDIT_COST_USD, assistCreditsFromUsd } from './assist-credits'\n\n/**\n * CREDITS GIVEN BACK (AGL-3595): what a month's meter reads once the credits\n * returned to it are taken off.\n *\n * Two meters count AI credits by the month, both as BILLED dollars in\n * `estCostUsd`:\n *\n * orgs/{orgId}/assistUsage/{YYYY-MM} the WORKSPACE's band\n * users/{uid}/aiUsage/{YYYY-MM} a Free workspace OWNER's account\n * allowance (`assist-free-taste.ts`)\n *\n * A give-back never rewrites `estCostUsd`. It adds to a second field,\n * `returnedUsd`, and records the act under `creditReturns.{key}` on the same\n * document, so the month still says what was spent, and what was handed\n * back, and by whom. Every reader that turns a meter into \"credits used\" —\n * the reservation, the customer's usage strip and banner, the staff card,\n * the overage the invoice prices — reads the figure through\n * `assistSpendAfterReturnsUsd`, so no surface can show a person a balance the\n * gate would not admit them at.\n *\n * The provider figure is NOT adjusted. `providerCostUsd` is what the month\n * cost Aglyn, and a give-back does not refund the model provider: margins\n * and spend alerts keep reading the real money.\n */\n\n/** The field a give-back adds to, in billed USD, beside `estCostUsd`. */\nexport const ASSIST_RETURNED_USD_FIELD = 'returnedUsd'\n\n/** The map of give-backs on a month document, keyed by idempotency key. */\nexport const ASSIST_CREDIT_RETURNS_FIELD = 'creditReturns'\n\n/** Which meter a give-back lands on. */\nexport type AssistCreditMeter = 'workspace' | 'account'\n\nconst round6 = (value: number): number => Math.round(value * 1_000_000) / 1_000_000\n\nconst nonNegative = (value: unknown): number => {\n const number = Number(value ?? 0)\n return Number.isFinite(number) && number > 0 ? number : 0\n}\n\n/**\n * The month's spend after give-backs, in billed USD — never below zero.\n *\n * Takes the two raw field values rather than a snapshot, so a transaction's\n * snapshot, a plain document and a test double all read the same way:\n * `assistSpendAfterReturnsUsd(snap.get('estCostUsd'), snap.get(ASSIST_RETURNED_USD_FIELD))`.\n *\n * Rounded to 6dp, the precision the meter writes at, so a give-back of\n * exactly what was spent reads as zero rather than as a float's residue —\n * a residue that `assistCreditsFromUsd` would round UP into a credit.\n */\nexport function assistSpendAfterReturnsUsd(\n estCostUsd: unknown,\n returnedUsd: unknown,\n): number {\n return Math.max(0, round6(nonNegative(estCostUsd) - nonNegative(returnedUsd)))\n}\n\n/** The same figure off a month document's data, for readers holding one. */\nexport function assistMonthSpendUsd(\n monthDoc: Record<string, unknown> | null | undefined,\n): number {\n return assistSpendAfterReturnsUsd(\n monthDoc?.['estCostUsd'],\n monthDoc?.[ASSIST_RETURNED_USD_FIELD],\n )\n}\n\n/** Credits given back on a month document so far. */\nexport function assistReturnedCredits(\n monthDoc: Record<string, unknown> | null | undefined,\n): number {\n // Nearest, not up: a give-back is a whole number of credits, or the whole\n // remainder of a meter, which is never more than one credit's fraction.\n const usd = nonNegative(monthDoc?.[ASSIST_RETURNED_USD_FIELD])\n return Math.round(usd / ASSIST_CREDIT_COST_USD)\n}\n\n/**\n * The most credits a give-back may return to a month: what the meter reads\n * as used after earlier give-backs. A give-back past it would bank credits\n * against next spend, which is a grant, not a compensation.\n */\nexport function assistReturnableCredits(\n monthDoc: Record<string, unknown> | null | undefined,\n): number {\n return assistCreditsFromUsd(assistMonthSpendUsd(monthDoc))\n}\n"],"names":["ASSIST_CREDIT_COST_USD","assistCreditsFromUsd","ASSIST_RETURNED_USD_FIELD","ASSIST_CREDIT_RETURNS_FIELD","round6","value","Math","round","nonNegative","number","Number","isFinite","assistSpendAfterReturnsUsd","estCostUsd","returnedUsd","max","assistMonthSpendUsd","monthDoc","assistReturnedCredits","usd","assistReturnableCredits"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,uEAAuE;AACvE,4EAA4E;AAC5E,SAASA,sBAAsB,EAAEC,oBAAoB,QAAQ,sBAAkB;AAE/E;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GAED,uEAAuE,GACvE,OAAO,MAAMC,4BAA4B,cAAa;AAEtD,yEAAyE,GACzE,OAAO,MAAMC,8BAA8B,gBAAe;AAK1D,MAAMC,SAAS,CAACC,QAA0BC,KAAKC,KAAK,CAACF,QAAQ,WAAa;AAE1E,MAAMG,cAAc,CAACH;IACnB,MAAMI,SAASC,OAAOL,gBAAAA,QAAS;IAC/B,OAAOK,OAAOC,QAAQ,CAACF,WAAWA,SAAS,IAAIA,SAAS;AAC1D;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAASG,2BACdC,UAAmB,EACnBC,WAAoB;IAEpB,OAAOR,KAAKS,GAAG,CAAC,GAAGX,OAAOI,YAAYK,cAAcL,YAAYM;AAClE;AAEA,0EAA0E,GAC1E,OAAO,SAASE,oBACdC,QAAoD;IAEpD,OAAOL,2BACLK,4BAAAA,QAAU,CAAC,aAAa,EACxBA,4BAAAA,QAAU,CAACf,0BAA0B;AAEzC;AAEA,mDAAmD,GACnD,OAAO,SAASgB,sBACdD,QAAoD;IAEpD,0EAA0E;IAC1E,wEAAwE;IACxE,MAAME,MAAMX,YAAYS,4BAAAA,QAAU,CAACf,0BAA0B;IAC7D,OAAOI,KAAKC,KAAK,CAACY,MAAMnB;AAC1B;AAEA;;;;CAIC,GACD,OAAO,SAASoB,wBACdH,QAAoD;IAEpD,OAAOhB,qBAAqBe,oBAAoBC;AAClD"}
|
|
@@ -18,6 +18,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
18
18
|
import { assistUsdFromCredits } from "./assist-credits.js";
|
|
19
19
|
import { resolveEffectivePlan } from "@aglyn/aglyn/app-utils/plan-entitlements";
|
|
20
20
|
import { FREE_AI_TASTE_CREDITS_PER_MONTH } from "../plan-entitlements.js";
|
|
21
|
+
import { ASSIST_RETURNED_USD_FIELD, assistSpendAfterReturnsUsd } from "./assist-credit-returns.js";
|
|
21
22
|
import { sendStaffAlertEmail } from "@aglyn/tenant-data-admin/server/staff-alert-email";
|
|
22
23
|
import { addAdminAudit } from "@aglyn/tenant-data-admin/server/admin-audit-write";
|
|
23
24
|
/**
|
|
@@ -131,14 +132,16 @@ export function platformFreeSpendRef(firestore, day) {
|
|
|
131
132
|
return firestore.collection(PLATFORM_AI_FREE_SPEND_COLLECTION).doc(day);
|
|
132
133
|
}
|
|
133
134
|
/** The two snapshots, as the transaction handed them over, into figures. */ export function freeTasteReadsFrom(account, platform, day) {
|
|
134
|
-
var _ref, _days_day,
|
|
135
|
+
var _ref, _days_day, _today_requests, _today_refusals, _ref1;
|
|
135
136
|
const days = (_ref = account == null ? void 0 : account.get('days')) != null ? _ref : {};
|
|
136
137
|
const today = (_days_day = days[day]) != null ? _days_day : {};
|
|
137
138
|
return {
|
|
138
|
-
|
|
139
|
+
// Net of credits given back (AGL-3595), the same figure every reader
|
|
140
|
+
// of this meter takes.
|
|
141
|
+
accountCostUsd: assistSpendAfterReturnsUsd(account == null ? void 0 : account.get('estCostUsd'), account == null ? void 0 : account.get(ASSIST_RETURNED_USD_FIELD)),
|
|
139
142
|
accountRequestsToday: Number((_today_requests = today.requests) != null ? _today_requests : 0),
|
|
140
143
|
accountRefusalsToday: Number((_today_refusals = today.refusals) != null ? _today_refusals : 0),
|
|
141
|
-
platformCostUsd: Number((
|
|
144
|
+
platformCostUsd: Number((_ref1 = platform == null ? void 0 : platform.get('estCostUsd')) != null ? _ref1 : 0),
|
|
142
145
|
platformPaused: Boolean(platform == null ? void 0 : platform.get('pausedAt'))
|
|
143
146
|
};
|
|
144
147
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/assist-free-taste.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 { FieldValue } from 'firebase-admin/firestore'\nimport {\n assistUsdFromCredits,\n type AssistRefusedBy,\n} from './assist-credits'\nimport { resolveEffectivePlan } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { FREE_AI_TASTE_CREDITS_PER_MONTH } from '../plan-entitlements'\nimport type { AglynOrgBilling } from '@aglyn/aglyn/foundation/definitions/org-billing.types'\nimport { sendStaffAlertEmail } from '@aglyn/tenant-data-admin/server/staff-alert-email'\nimport { addAdminAudit } from '@aglyn/tenant-data-admin/server/admin-audit-write'\n\n/**\n * The Free AI taste's precautions (AGL-2925) — the half of the meter that\n * exists only because the Free band has no invoice behind it.\n *\n * `FREE_AI_TASTE_CREDITS_PER_MONTH` gives every Free workspace 300 credits a\n * month as a wall. A wall per WORKSPACE is not a bound per PERSON: one\n * account may hold three free workspaces (AGL-2265), a script can mint\n * accounts faster than a person can, and a refused brief costs the same\n * tokens as an answered one. So the taste is metered a second time, per\n * account, and bounded three more ways. Everything below is read and\n * decided INSIDE `reserveAssistMessage`'s transaction, so a burst of\n * concurrent requests cannot each read \"under\" and all proceed — the same\n * property every other rung of that reservation has.\n *\n * ## Documents (both ABSENT from firebase-firestore.rules on purpose)\n *\n * users/{uid}/aiUsage/{YYYY-MM} the ACCOUNT's month:\n * { month, estCostUsd, requests, days: { [YYYY-MM-DD]: { requests,\n * refusals } }, updatedAt }\n * platformAiFreeSpend/{YYYY-MM-DD} the PLATFORM's day of free spend:\n * { day, estCostUsd, requests, refusals, alertedAt?, pausedAt?,\n * updatedAt }\n *\n * The account document sits under `users/{uid}`, whose owner may read and\n * write the user document itself. It may not touch this subcollection: the\n * `users` block matches its subcollections BY NAME and carries no wildcard,\n * so an unmatched name is default-deny for every client, staff included —\n * the same property `assistUsage` relies on one level down. An owner who\n * could write here could reset their own allowance, which is the whole\n * meter. The platform document is a top-level collection alongside\n * `platformCronBeats`, unmatched and therefore closed, and every reader is\n * an Admin-SDK reader behind the staff gate.\n *\n * ## Whose account\n *\n * The WORKSPACE OWNER's. A member invited to someone else's free workspace\n * draws from that workspace's band and from its owner's allowance, never\n * from their own account: the owner is the person the free workspace is\n * attributed to for the ceiling on how many they may hold, and attributing\n * the spend the same way is what keeps the two bounds describing one\n * person. `ownerUid` first, `createdByUid` for an org that predates\n * ownership stamping — the same union the free-workspace count reads.\n */\n\n/** Where an account's month of free AI spend lives, under `users/{uid}`. */\nexport const FREE_AI_ACCOUNT_USAGE_COLLECTION = 'aiUsage'\n\n/** The top-level collection holding one document per UTC day of free spend. */\nexport const PLATFORM_AI_FREE_SPEND_COLLECTION = 'platformAiFreeSpend'\n\n/**\n * Refusals a day before an account's free generation pauses. Three, not\n * one: a single declined brief is a normal outcome of asking an assistant\n * for things, and pausing on it would punish an ordinary afternoon. Three\n * in a day is somebody probing what the model will write.\n */\nexport const AI_FREE_REFUSALS_PER_DAY = 3\n\n/** The share of the platform ceiling at which staff are told, once a day. */\nexport const AI_FREE_PLATFORM_ALERT_SHARE = 0.8\n\n/** The account allowance, in USD of provider spend — the taste, per person. */\nexport const FREE_AI_ACCOUNT_BUDGET_USD = assistUsdFromCredits(\n FREE_AI_TASTE_CREDITS_PER_MONTH,\n)\n\n/**\n * Free requests one account may make a UTC day, across its workspaces\n * (`AI_FREE_DAILY_REQUESTS`, default 30).\n *\n * A request, not a message: this counts every reservation the account's\n * free workspaces take, at either door. Thirty is three times the\n * per-workspace message cap, so one workspace never hits it before its own\n * cap and three workspaces hit it exactly when their caps would have\n * summed — it bounds the multiplier, not the ordinary day. Zero is honored\n * as \"no free requests\", which is a decision an operator may write down;\n * junk and an empty value take the default.\n */\nexport function aiFreeDailyRequests(): number {\n const raw = process.env.AI_FREE_DAILY_REQUESTS\n const parsed = raw ? Number(raw) : Number.NaN\n return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 30\n}\n\n/**\n * The platform-wide ceiling on ONE UTC day of free-tier provider spend\n * (`AI_FREE_DAILY_PLATFORM_CEILING_USD`, default $25).\n *\n * Every other bound here is per something — per workspace, per account,\n * per address — and a wave of new accounts clears all of them one at a\n * time. This is the bound on the sum. At 80% of it staff are mailed; at\n * 100% every free reservation refuses until the day rolls, paid workspaces\n * untouched, and the manual `ai-generate` lockdown key remains the switch\n * for anything the ceiling did not catch.\n *\n * Fails to the DEFAULT, never to \"no ceiling\", for the reason the operator\n * COGS limit does: an empty, negative, zero or unparseable value is not a\n * decision to run unbounded. There is deliberately no `off` word — a\n * deployment that wants no ceiling sets a figure it is content to spend.\n */\nexport function aiFreeDailyPlatformCeilingUsd(): number {\n const raw = String(process.env.AI_FREE_DAILY_PLATFORM_CEILING_USD ?? '').trim()\n const parsed = raw ? Number(raw) : Number.NaN\n return Number.isFinite(parsed) && parsed > 0 ? parsed : 25\n}\n\n/** The account a free workspace's spend is attributed to. */\nexport interface FreeAssistAccount {\n /**\n * The workspace owner's uid, or `null` for an org document that names no\n * owner and no creator — every org created since ownership was stamped\n * names one, so `null` is the shape of a fixture or a very old record.\n * The account rungs are skipped for it; the org's own band still binds.\n */\n accountUid: string | null\n}\n\n/** The org fields the attribution reads, beside the billing ones. */\nexport type AssistMeteredOrg = Partial<AglynOrgBilling> & {\n ownerUid?: string | null\n createdByUid?: string | null\n}\n\n/**\n * The account to meter a workspace's free spend against, or `null` when the\n * workspace is not on the Free plan and none of this applies.\n *\n * `resolveEffectivePlan`, not `org.plan`: a workspace whose subscription\n * died is Free again, and its spend is the taste's spend again.\n */\nexport function freeAssistAccount(\n org: AssistMeteredOrg | null | undefined,\n): FreeAssistAccount | null {\n if (resolveEffectivePlan(org) !== 'free') return null\n const owner = String(org?.ownerUid ?? '').trim()\n const creator = String(org?.createdByUid ?? '').trim()\n return { accountUid: owner || creator || null }\n}\n\nexport function freeAccountUsageRef(\n firestore: FirebaseFirestore.Firestore,\n accountUid: string,\n month: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('users')\n .doc(accountUid)\n .collection(FREE_AI_ACCOUNT_USAGE_COLLECTION)\n .doc(month)\n}\n\nexport function platformFreeSpendRef(\n firestore: FirebaseFirestore.Firestore,\n day: string,\n): FirebaseFirestore.DocumentReference {\n return firestore.collection(PLATFORM_AI_FREE_SPEND_COLLECTION).doc(day)\n}\n\n/** What the reservation read about the account and the platform. */\nexport interface FreeTasteReads {\n accountCostUsd: number\n accountRequestsToday: number\n accountRefusalsToday: number\n platformCostUsd: number\n platformPaused: boolean\n}\n\n/** The two snapshots, as the transaction handed them over, into figures. */\nexport function freeTasteReadsFrom(\n account: FirebaseFirestore.DocumentSnapshot | null,\n platform: FirebaseFirestore.DocumentSnapshot | null,\n day: string,\n): FreeTasteReads {\n const days = (account?.get('days') ?? {}) as Record<\n string,\n { requests?: unknown; refusals?: unknown } | undefined\n >\n const today = days[day] ?? {}\n return {\n accountCostUsd: Number(account?.get('estCostUsd') ?? 0),\n accountRequestsToday: Number(today.requests ?? 0),\n accountRefusalsToday: Number(today.refusals ?? 0),\n platformCostUsd: Number(platform?.get('estCostUsd') ?? 0),\n platformPaused: Boolean(platform?.get('pausedAt')),\n }\n}\n\n/**\n * Which of the taste's own precautions refuses, or `null` when none does —\n * pure, so the spec can force every rung red without a Firestore.\n *\n * Checked in the order the reads are cheapest to explain: the daily\n * request cap and the refusal pause (both reset on a clock the customer\n * can be told about), then the account's month (an upgrade is the way\n * out), then the platform ceiling (nobody's doing; try again tomorrow).\n * The org's own rungs — its daily message cap, its band — run before this\n * in the reservation, so a workspace at its own band hears about its own\n * band rather than about its owner's other workspaces.\n *\n * Every comparison is `>=` against a count the reservation is about to\n * increment, so a cap of N admits exactly N. A paused platform refuses\n * regardless of the running figure: the pause is a decision recorded on\n * the document, and a ceiling raised by an operator mid-day does not undo\n * it — the day rolls, or staff clear the document.\n */\nexport function freeTasteRefusal(\n reads: FreeTasteReads,\n limits: {\n dailyRequests?: number\n refusalsPerDay?: number\n accountBudgetUsd?: number\n platformCeilingUsd?: number\n } = {},\n): Extract<AssistRefusedBy, 'requests' | 'refusals' | 'account' | 'platform'> | null {\n const dailyRequests = limits.dailyRequests ?? aiFreeDailyRequests()\n const refusalsPerDay = limits.refusalsPerDay ?? AI_FREE_REFUSALS_PER_DAY\n const accountBudgetUsd = limits.accountBudgetUsd ?? FREE_AI_ACCOUNT_BUDGET_USD\n const platformCeilingUsd =\n limits.platformCeilingUsd ?? aiFreeDailyPlatformCeilingUsd()\n if (reads.accountRequestsToday >= dailyRequests) return 'requests'\n if (reads.accountRefusalsToday >= refusalsPerDay) return 'refusals'\n if (reads.accountCostUsd >= accountBudgetUsd) return 'account'\n if (reads.platformPaused || reads.platformCostUsd >= platformCeilingUsd) {\n return 'platform'\n }\n return null\n}\n\n/**\n * The account-side write one admitted free reservation owes: a request\n * counted for the month and for the day. `set(…, { merge: true })` with\n * nested increments, so the first request of a month conjures the document\n * and a day's map entry appears the first time that day is counted.\n */\nexport function freeAccountReservationWrite(\n month: string,\n day: string,\n): Record<string, unknown> {\n const increment = FieldValue.increment\n return {\n month,\n requests: increment(1),\n days: { [day]: { requests: increment(1) } },\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n\n/**\n * The two writes one metered free turn owes, beside the org's own rollup.\n *\n * A `refusal` stop is the one case where the meters disagree on purpose. The\n * tokens were spent, so the PLATFORM document — which bounds our money —\n * takes the cost and counts the refusal. The ACCOUNT's credits do not move:\n * a refusal never draws credits, because a customer whose brief was\n * declined has not been given anything, and because a run of declined\n * briefs is answered by the refusal pause rather than by an emptied band.\n * The account's day counts the refusal, which is what the pause reads.\n */\nexport function freeTasteMeterWrites(input: {\n estCostUsd: number\n refused: boolean\n day: string\n month: string\n}): { account: Record<string, unknown>; platform: Record<string, unknown> } {\n const increment = FieldValue.increment\n const serverTimestamp = FieldValue.serverTimestamp\n const { estCostUsd, refused, day, month } = input\n return {\n account: refused\n ? {\n month,\n days: { [day]: { refusals: increment(1) } },\n updatedAt: serverTimestamp(),\n }\n : { month, estCostUsd: increment(estCostUsd), updatedAt: serverTimestamp() },\n platform: {\n day,\n estCostUsd: increment(estCostUsd),\n requests: increment(1),\n refusals: increment(refused ? 1 : 0),\n updatedAt: serverTimestamp(),\n },\n }\n}\n\n/**\n * After a free turn is metered: tell staff at 80% of the day's ceiling,\n * and record the pause at 100% — each ONCE per UTC day, deduplicated on\n * the day document itself inside a transaction, so two turns landing\n * together cannot both cross a threshold and both announce it.\n *\n * The refusal at the ceiling is NOT decided here; `freeTasteRefusal` reads\n * the figure inside every reservation and refuses on it directly. This\n * only says so out loud: the email, and an `adminAudit` row under\n * `platform.aiFreeSpend.paused` so the staff audit trail shows the day the\n * taste switched itself off. Both are best-effort and neither can fail the\n * turn that triggered them — the turn is already paid for.\n *\n * Returns what it announced, for the spec and for the log.\n */\nexport async function announcePlatformFreeSpend(\n firestore: FirebaseFirestore.Firestore,\n day: string,\n): Promise<{ alerted: boolean; paused: boolean; estCostUsd: number }> {\n const ceilingUsd = aiFreeDailyPlatformCeilingUsd()\n const ref = platformFreeSpendRef(firestore, day)\n const crossed = await firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(ref)\n const estCostUsd = Number(snapshot.get('estCostUsd') ?? 0)\n const alert =\n !snapshot.get('alertedAt') &&\n estCostUsd >= ceilingUsd * AI_FREE_PLATFORM_ALERT_SHARE\n const pause = !snapshot.get('pausedAt') && estCostUsd >= ceilingUsd\n if (alert || pause) {\n tx.set(\n ref,\n {\n ...(alert ? { alertedAt: FieldValue.serverTimestamp() } : {}),\n ...(pause ? { pausedAt: FieldValue.serverTimestamp() } : {}),\n },\n { merge: true },\n )\n }\n return { alerted: alert, paused: pause, estCostUsd }\n })\n if (!crossed.alerted && !crossed.paused) return crossed\n\n const figure = `$${crossed.estCostUsd.toFixed(2)} of the $${ceilingUsd.toFixed(2)} ceiling`\n if (crossed.paused) {\n await addAdminAudit(firestore, {\n actorUid: 'system:ai-free-spend',\n action: 'platform.aiFreeSpend.paused',\n target: `${PLATFORM_AI_FREE_SPEND_COLLECTION}/${day}`,\n after: { day, estCostUsd: crossed.estCostUsd, ceilingUsd },\n at: FieldValue.serverTimestamp(),\n })\n .catch((error) =>\n console.error('[ai-free-spend] audit row failed', error),\n )\n }\n await sendStaffAlertEmail({\n subject: crossed.paused\n ? `Free AI generation paused for ${day}: ${figure}`\n : `Free AI spend at 80% of today's ceiling (${day}): ${figure}`,\n text: crossed.paused\n ? `Free-tier AI spend for ${day} (UTC) reached ${figure} ` +\n '(AI_FREE_DAILY_PLATFORM_CEILING_USD). Every Free workspace is ' +\n 'refused AI generation until the UTC day rolls; paid workspaces are ' +\n 'unaffected. If this is an abuse wave rather than organic use, the ' +\n '`ai-generate` feature lock on Staff → Lockdown stops generation ' +\n 'for everyone, and the assist signals page shows which workspaces ' +\n 'spent it.'\n : `Free-tier AI spend for ${day} (UTC) is at ${figure} ` +\n '(AI_FREE_DAILY_PLATFORM_CEILING_USD). At 100% every Free ' +\n 'workspace is refused AI generation until the day rolls. The ' +\n 'assist signals page shows the day so far; the `ai-generate` ' +\n 'feature lock on Staff → Lockdown is the manual stop.',\n context: 'ai-free-spend',\n })\n return crossed\n}\n\n/** The staff readout of today's free spend, for the assist signals page. */\nexport interface PlatformFreeSpendReadout {\n day: string\n estCostUsd: number\n requests: number\n refusals: number\n ceilingUsd: number\n /** True once the 80% mail went out today. */\n alerted: boolean\n /** True while free generation is refused for the day. */\n paused: boolean\n}\n\nexport async function readPlatformFreeSpend(\n firestore: FirebaseFirestore.Firestore,\n day: string,\n): Promise<PlatformFreeSpendReadout> {\n const snapshot = await platformFreeSpendRef(firestore, day).get()\n const ceilingUsd = aiFreeDailyPlatformCeilingUsd()\n const estCostUsd = Number(snapshot.get('estCostUsd') ?? 0)\n return {\n day,\n estCostUsd,\n requests: Number(snapshot.get('requests') ?? 0),\n refusals: Number(snapshot.get('refusals') ?? 0),\n ceilingUsd,\n alerted: Boolean(snapshot.get('alertedAt')),\n // The pause is the recorded decision OR the live figure: a day that\n // crossed the ceiling before anything announced it is paused all the\n // same, because the reservation refuses on the figure.\n paused: Boolean(snapshot.get('pausedAt')) || estCostUsd >= ceilingUsd,\n }\n}\n"],"names":["FieldValue","assistUsdFromCredits","resolveEffectivePlan","FREE_AI_TASTE_CREDITS_PER_MONTH","sendStaffAlertEmail","addAdminAudit","FREE_AI_ACCOUNT_USAGE_COLLECTION","PLATFORM_AI_FREE_SPEND_COLLECTION","AI_FREE_REFUSALS_PER_DAY","AI_FREE_PLATFORM_ALERT_SHARE","FREE_AI_ACCOUNT_BUDGET_USD","aiFreeDailyRequests","raw","process","env","AI_FREE_DAILY_REQUESTS","parsed","Number","NaN","isFinite","Math","floor","aiFreeDailyPlatformCeilingUsd","String","AI_FREE_DAILY_PLATFORM_CEILING_USD","trim","freeAssistAccount","org","owner","ownerUid","creator","createdByUid","accountUid","freeAccountUsageRef","firestore","month","collection","doc","platformFreeSpendRef","day","freeTasteReadsFrom","account","platform","days","today","get","accountCostUsd","accountRequestsToday","requests","accountRefusalsToday","refusals","platformCostUsd","platformPaused","Boolean","freeTasteRefusal","reads","limits","dailyRequests","refusalsPerDay","accountBudgetUsd","platformCeilingUsd","freeAccountReservationWrite","increment","updatedAt","serverTimestamp","freeTasteMeterWrites","input","estCostUsd","refused","announcePlatformFreeSpend","ceilingUsd","ref","crossed","runTransaction","tx","snapshot","alert","pause","set","alertedAt","pausedAt","merge","alerted","paused","figure","toFixed","actorUid","action","target","after","at","catch","error","console","subject","text","context","readPlatformFreeSpend"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/assist-free-taste.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 { FieldValue } from 'firebase-admin/firestore'\nimport {\n assistUsdFromCredits,\n type AssistRefusedBy,\n} from './assist-credits'\nimport { resolveEffectivePlan } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { FREE_AI_TASTE_CREDITS_PER_MONTH } from '../plan-entitlements'\nimport {\n ASSIST_RETURNED_USD_FIELD,\n assistSpendAfterReturnsUsd,\n} from './assist-credit-returns'\nimport type { AglynOrgBilling } from '@aglyn/aglyn/foundation/definitions/org-billing.types'\nimport { sendStaffAlertEmail } from '@aglyn/tenant-data-admin/server/staff-alert-email'\nimport { addAdminAudit } from '@aglyn/tenant-data-admin/server/admin-audit-write'\n\n/**\n * The Free AI taste's precautions (AGL-2925) — the half of the meter that\n * exists only because the Free band has no invoice behind it.\n *\n * `FREE_AI_TASTE_CREDITS_PER_MONTH` gives every Free workspace 300 credits a\n * month as a wall. A wall per WORKSPACE is not a bound per PERSON: one\n * account may hold three free workspaces (AGL-2265), a script can mint\n * accounts faster than a person can, and a refused brief costs the same\n * tokens as an answered one. So the taste is metered a second time, per\n * account, and bounded three more ways. Everything below is read and\n * decided INSIDE `reserveAssistMessage`'s transaction, so a burst of\n * concurrent requests cannot each read \"under\" and all proceed — the same\n * property every other rung of that reservation has.\n *\n * ## Documents (both ABSENT from firebase-firestore.rules on purpose)\n *\n * users/{uid}/aiUsage/{YYYY-MM} the ACCOUNT's month:\n * { month, estCostUsd, requests, days: { [YYYY-MM-DD]: { requests,\n * refusals } }, updatedAt }\n * platformAiFreeSpend/{YYYY-MM-DD} the PLATFORM's day of free spend:\n * { day, estCostUsd, requests, refusals, alertedAt?, pausedAt?,\n * updatedAt }\n *\n * The account document sits under `users/{uid}`, whose owner may read and\n * write the user document itself. It may not touch this subcollection: the\n * `users` block matches its subcollections BY NAME and carries no wildcard,\n * so an unmatched name is default-deny for every client, staff included —\n * the same property `assistUsage` relies on one level down. An owner who\n * could write here could reset their own allowance, which is the whole\n * meter. The platform document is a top-level collection alongside\n * `platformCronBeats`, unmatched and therefore closed, and every reader is\n * an Admin-SDK reader behind the staff gate.\n *\n * ## Whose account\n *\n * The WORKSPACE OWNER's. A member invited to someone else's free workspace\n * draws from that workspace's band and from its owner's allowance, never\n * from their own account: the owner is the person the free workspace is\n * attributed to for the ceiling on how many they may hold, and attributing\n * the spend the same way is what keeps the two bounds describing one\n * person. `ownerUid` first, `createdByUid` for an org that predates\n * ownership stamping — the same union the free-workspace count reads.\n */\n\n/** Where an account's month of free AI spend lives, under `users/{uid}`. */\nexport const FREE_AI_ACCOUNT_USAGE_COLLECTION = 'aiUsage'\n\n/** The top-level collection holding one document per UTC day of free spend. */\nexport const PLATFORM_AI_FREE_SPEND_COLLECTION = 'platformAiFreeSpend'\n\n/**\n * Refusals a day before an account's free generation pauses. Three, not\n * one: a single declined brief is a normal outcome of asking an assistant\n * for things, and pausing on it would punish an ordinary afternoon. Three\n * in a day is somebody probing what the model will write.\n */\nexport const AI_FREE_REFUSALS_PER_DAY = 3\n\n/** The share of the platform ceiling at which staff are told, once a day. */\nexport const AI_FREE_PLATFORM_ALERT_SHARE = 0.8\n\n/** The account allowance, in USD of provider spend — the taste, per person. */\nexport const FREE_AI_ACCOUNT_BUDGET_USD = assistUsdFromCredits(\n FREE_AI_TASTE_CREDITS_PER_MONTH,\n)\n\n/**\n * Free requests one account may make a UTC day, across its workspaces\n * (`AI_FREE_DAILY_REQUESTS`, default 30).\n *\n * A request, not a message: this counts every reservation the account's\n * free workspaces take, at either door. Thirty is three times the\n * per-workspace message cap, so one workspace never hits it before its own\n * cap and three workspaces hit it exactly when their caps would have\n * summed — it bounds the multiplier, not the ordinary day. Zero is honored\n * as \"no free requests\", which is a decision an operator may write down;\n * junk and an empty value take the default.\n */\nexport function aiFreeDailyRequests(): number {\n const raw = process.env.AI_FREE_DAILY_REQUESTS\n const parsed = raw ? Number(raw) : Number.NaN\n return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 30\n}\n\n/**\n * The platform-wide ceiling on ONE UTC day of free-tier provider spend\n * (`AI_FREE_DAILY_PLATFORM_CEILING_USD`, default $25).\n *\n * Every other bound here is per something — per workspace, per account,\n * per address — and a wave of new accounts clears all of them one at a\n * time. This is the bound on the sum. At 80% of it staff are mailed; at\n * 100% every free reservation refuses until the day rolls, paid workspaces\n * untouched, and the manual `ai-generate` lockdown key remains the switch\n * for anything the ceiling did not catch.\n *\n * Fails to the DEFAULT, never to \"no ceiling\", for the reason the operator\n * COGS limit does: an empty, negative, zero or unparseable value is not a\n * decision to run unbounded. There is deliberately no `off` word — a\n * deployment that wants no ceiling sets a figure it is content to spend.\n */\nexport function aiFreeDailyPlatformCeilingUsd(): number {\n const raw = String(process.env.AI_FREE_DAILY_PLATFORM_CEILING_USD ?? '').trim()\n const parsed = raw ? Number(raw) : Number.NaN\n return Number.isFinite(parsed) && parsed > 0 ? parsed : 25\n}\n\n/** The account a free workspace's spend is attributed to. */\nexport interface FreeAssistAccount {\n /**\n * The workspace owner's uid, or `null` for an org document that names no\n * owner and no creator — every org created since ownership was stamped\n * names one, so `null` is the shape of a fixture or a very old record.\n * The account rungs are skipped for it; the org's own band still binds.\n */\n accountUid: string | null\n}\n\n/** The org fields the attribution reads, beside the billing ones. */\nexport type AssistMeteredOrg = Partial<AglynOrgBilling> & {\n ownerUid?: string | null\n createdByUid?: string | null\n}\n\n/**\n * The account to meter a workspace's free spend against, or `null` when the\n * workspace is not on the Free plan and none of this applies.\n *\n * `resolveEffectivePlan`, not `org.plan`: a workspace whose subscription\n * died is Free again, and its spend is the taste's spend again.\n */\nexport function freeAssistAccount(\n org: AssistMeteredOrg | null | undefined,\n): FreeAssistAccount | null {\n if (resolveEffectivePlan(org) !== 'free') return null\n const owner = String(org?.ownerUid ?? '').trim()\n const creator = String(org?.createdByUid ?? '').trim()\n return { accountUid: owner || creator || null }\n}\n\nexport function freeAccountUsageRef(\n firestore: FirebaseFirestore.Firestore,\n accountUid: string,\n month: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('users')\n .doc(accountUid)\n .collection(FREE_AI_ACCOUNT_USAGE_COLLECTION)\n .doc(month)\n}\n\nexport function platformFreeSpendRef(\n firestore: FirebaseFirestore.Firestore,\n day: string,\n): FirebaseFirestore.DocumentReference {\n return firestore.collection(PLATFORM_AI_FREE_SPEND_COLLECTION).doc(day)\n}\n\n/** What the reservation read about the account and the platform. */\nexport interface FreeTasteReads {\n accountCostUsd: number\n accountRequestsToday: number\n accountRefusalsToday: number\n platformCostUsd: number\n platformPaused: boolean\n}\n\n/** The two snapshots, as the transaction handed them over, into figures. */\nexport function freeTasteReadsFrom(\n account: FirebaseFirestore.DocumentSnapshot | null,\n platform: FirebaseFirestore.DocumentSnapshot | null,\n day: string,\n): FreeTasteReads {\n const days = (account?.get('days') ?? {}) as Record<\n string,\n { requests?: unknown; refusals?: unknown } | undefined\n >\n const today = days[day] ?? {}\n return {\n // Net of credits given back (AGL-3595), the same figure every reader\n // of this meter takes.\n accountCostUsd: assistSpendAfterReturnsUsd(\n account?.get('estCostUsd'),\n account?.get(ASSIST_RETURNED_USD_FIELD),\n ),\n accountRequestsToday: Number(today.requests ?? 0),\n accountRefusalsToday: Number(today.refusals ?? 0),\n platformCostUsd: Number(platform?.get('estCostUsd') ?? 0),\n platformPaused: Boolean(platform?.get('pausedAt')),\n }\n}\n\n/**\n * Which of the taste's own precautions refuses, or `null` when none does —\n * pure, so the spec can force every rung red without a Firestore.\n *\n * Checked in the order the reads are cheapest to explain: the daily\n * request cap and the refusal pause (both reset on a clock the customer\n * can be told about), then the account's month (an upgrade is the way\n * out), then the platform ceiling (nobody's doing; try again tomorrow).\n * The org's own rungs — its daily message cap, its band — run before this\n * in the reservation, so a workspace at its own band hears about its own\n * band rather than about its owner's other workspaces.\n *\n * Every comparison is `>=` against a count the reservation is about to\n * increment, so a cap of N admits exactly N. A paused platform refuses\n * regardless of the running figure: the pause is a decision recorded on\n * the document, and a ceiling raised by an operator mid-day does not undo\n * it — the day rolls, or staff clear the document.\n */\nexport function freeTasteRefusal(\n reads: FreeTasteReads,\n limits: {\n dailyRequests?: number\n refusalsPerDay?: number\n accountBudgetUsd?: number\n platformCeilingUsd?: number\n } = {},\n): Extract<AssistRefusedBy, 'requests' | 'refusals' | 'account' | 'platform'> | null {\n const dailyRequests = limits.dailyRequests ?? aiFreeDailyRequests()\n const refusalsPerDay = limits.refusalsPerDay ?? AI_FREE_REFUSALS_PER_DAY\n const accountBudgetUsd = limits.accountBudgetUsd ?? FREE_AI_ACCOUNT_BUDGET_USD\n const platformCeilingUsd =\n limits.platformCeilingUsd ?? aiFreeDailyPlatformCeilingUsd()\n if (reads.accountRequestsToday >= dailyRequests) return 'requests'\n if (reads.accountRefusalsToday >= refusalsPerDay) return 'refusals'\n if (reads.accountCostUsd >= accountBudgetUsd) return 'account'\n if (reads.platformPaused || reads.platformCostUsd >= platformCeilingUsd) {\n return 'platform'\n }\n return null\n}\n\n/**\n * The account-side write one admitted free reservation owes: a request\n * counted for the month and for the day. `set(…, { merge: true })` with\n * nested increments, so the first request of a month conjures the document\n * and a day's map entry appears the first time that day is counted.\n */\nexport function freeAccountReservationWrite(\n month: string,\n day: string,\n): Record<string, unknown> {\n const increment = FieldValue.increment\n return {\n month,\n requests: increment(1),\n days: { [day]: { requests: increment(1) } },\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n\n/**\n * The two writes one metered free turn owes, beside the org's own rollup.\n *\n * A `refusal` stop is the one case where the meters disagree on purpose. The\n * tokens were spent, so the PLATFORM document — which bounds our money —\n * takes the cost and counts the refusal. The ACCOUNT's credits do not move:\n * a refusal never draws credits, because a customer whose brief was\n * declined has not been given anything, and because a run of declined\n * briefs is answered by the refusal pause rather than by an emptied band.\n * The account's day counts the refusal, which is what the pause reads.\n */\nexport function freeTasteMeterWrites(input: {\n estCostUsd: number\n refused: boolean\n day: string\n month: string\n}): { account: Record<string, unknown>; platform: Record<string, unknown> } {\n const increment = FieldValue.increment\n const serverTimestamp = FieldValue.serverTimestamp\n const { estCostUsd, refused, day, month } = input\n return {\n account: refused\n ? {\n month,\n days: { [day]: { refusals: increment(1) } },\n updatedAt: serverTimestamp(),\n }\n : { month, estCostUsd: increment(estCostUsd), updatedAt: serverTimestamp() },\n platform: {\n day,\n estCostUsd: increment(estCostUsd),\n requests: increment(1),\n refusals: increment(refused ? 1 : 0),\n updatedAt: serverTimestamp(),\n },\n }\n}\n\n/**\n * After a free turn is metered: tell staff at 80% of the day's ceiling,\n * and record the pause at 100% — each ONCE per UTC day, deduplicated on\n * the day document itself inside a transaction, so two turns landing\n * together cannot both cross a threshold and both announce it.\n *\n * The refusal at the ceiling is NOT decided here; `freeTasteRefusal` reads\n * the figure inside every reservation and refuses on it directly. This\n * only says so out loud: the email, and an `adminAudit` row under\n * `platform.aiFreeSpend.paused` so the staff audit trail shows the day the\n * taste switched itself off. Both are best-effort and neither can fail the\n * turn that triggered them — the turn is already paid for.\n *\n * Returns what it announced, for the spec and for the log.\n */\nexport async function announcePlatformFreeSpend(\n firestore: FirebaseFirestore.Firestore,\n day: string,\n): Promise<{ alerted: boolean; paused: boolean; estCostUsd: number }> {\n const ceilingUsd = aiFreeDailyPlatformCeilingUsd()\n const ref = platformFreeSpendRef(firestore, day)\n const crossed = await firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(ref)\n const estCostUsd = Number(snapshot.get('estCostUsd') ?? 0)\n const alert =\n !snapshot.get('alertedAt') &&\n estCostUsd >= ceilingUsd * AI_FREE_PLATFORM_ALERT_SHARE\n const pause = !snapshot.get('pausedAt') && estCostUsd >= ceilingUsd\n if (alert || pause) {\n tx.set(\n ref,\n {\n ...(alert ? { alertedAt: FieldValue.serverTimestamp() } : {}),\n ...(pause ? { pausedAt: FieldValue.serverTimestamp() } : {}),\n },\n { merge: true },\n )\n }\n return { alerted: alert, paused: pause, estCostUsd }\n })\n if (!crossed.alerted && !crossed.paused) return crossed\n\n const figure = `$${crossed.estCostUsd.toFixed(2)} of the $${ceilingUsd.toFixed(2)} ceiling`\n if (crossed.paused) {\n await addAdminAudit(firestore, {\n actorUid: 'system:ai-free-spend',\n action: 'platform.aiFreeSpend.paused',\n target: `${PLATFORM_AI_FREE_SPEND_COLLECTION}/${day}`,\n after: { day, estCostUsd: crossed.estCostUsd, ceilingUsd },\n at: FieldValue.serverTimestamp(),\n })\n .catch((error) =>\n console.error('[ai-free-spend] audit row failed', error),\n )\n }\n await sendStaffAlertEmail({\n subject: crossed.paused\n ? `Free AI generation paused for ${day}: ${figure}`\n : `Free AI spend at 80% of today's ceiling (${day}): ${figure}`,\n text: crossed.paused\n ? `Free-tier AI spend for ${day} (UTC) reached ${figure} ` +\n '(AI_FREE_DAILY_PLATFORM_CEILING_USD). Every Free workspace is ' +\n 'refused AI generation until the UTC day rolls; paid workspaces are ' +\n 'unaffected. If this is an abuse wave rather than organic use, the ' +\n '`ai-generate` feature lock on Staff → Lockdown stops generation ' +\n 'for everyone, and the assist signals page shows which workspaces ' +\n 'spent it.'\n : `Free-tier AI spend for ${day} (UTC) is at ${figure} ` +\n '(AI_FREE_DAILY_PLATFORM_CEILING_USD). At 100% every Free ' +\n 'workspace is refused AI generation until the day rolls. The ' +\n 'assist signals page shows the day so far; the `ai-generate` ' +\n 'feature lock on Staff → Lockdown is the manual stop.',\n context: 'ai-free-spend',\n })\n return crossed\n}\n\n/** The staff readout of today's free spend, for the assist signals page. */\nexport interface PlatformFreeSpendReadout {\n day: string\n estCostUsd: number\n requests: number\n refusals: number\n ceilingUsd: number\n /** True once the 80% mail went out today. */\n alerted: boolean\n /** True while free generation is refused for the day. */\n paused: boolean\n}\n\nexport async function readPlatformFreeSpend(\n firestore: FirebaseFirestore.Firestore,\n day: string,\n): Promise<PlatformFreeSpendReadout> {\n const snapshot = await platformFreeSpendRef(firestore, day).get()\n const ceilingUsd = aiFreeDailyPlatformCeilingUsd()\n const estCostUsd = Number(snapshot.get('estCostUsd') ?? 0)\n return {\n day,\n estCostUsd,\n requests: Number(snapshot.get('requests') ?? 0),\n refusals: Number(snapshot.get('refusals') ?? 0),\n ceilingUsd,\n alerted: Boolean(snapshot.get('alertedAt')),\n // The pause is the recorded decision OR the live figure: a day that\n // crossed the ceiling before anything announced it is paused all the\n // same, because the reservation refuses on the figure.\n paused: Boolean(snapshot.get('pausedAt')) || estCostUsd >= ceilingUsd,\n }\n}\n"],"names":["FieldValue","assistUsdFromCredits","resolveEffectivePlan","FREE_AI_TASTE_CREDITS_PER_MONTH","ASSIST_RETURNED_USD_FIELD","assistSpendAfterReturnsUsd","sendStaffAlertEmail","addAdminAudit","FREE_AI_ACCOUNT_USAGE_COLLECTION","PLATFORM_AI_FREE_SPEND_COLLECTION","AI_FREE_REFUSALS_PER_DAY","AI_FREE_PLATFORM_ALERT_SHARE","FREE_AI_ACCOUNT_BUDGET_USD","aiFreeDailyRequests","raw","process","env","AI_FREE_DAILY_REQUESTS","parsed","Number","NaN","isFinite","Math","floor","aiFreeDailyPlatformCeilingUsd","String","AI_FREE_DAILY_PLATFORM_CEILING_USD","trim","freeAssistAccount","org","owner","ownerUid","creator","createdByUid","accountUid","freeAccountUsageRef","firestore","month","collection","doc","platformFreeSpendRef","day","freeTasteReadsFrom","account","platform","days","today","get","accountCostUsd","accountRequestsToday","requests","accountRefusalsToday","refusals","platformCostUsd","platformPaused","Boolean","freeTasteRefusal","reads","limits","dailyRequests","refusalsPerDay","accountBudgetUsd","platformCeilingUsd","freeAccountReservationWrite","increment","updatedAt","serverTimestamp","freeTasteMeterWrites","input","estCostUsd","refused","announcePlatformFreeSpend","ceilingUsd","ref","crossed","runTransaction","tx","snapshot","alert","pause","set","alertedAt","pausedAt","merge","alerted","paused","figure","toFixed","actorUid","action","target","after","at","catch","error","console","subject","text","context","readPlatformFreeSpend"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,QAAQ,2BAA0B;AACrD,SACEC,oBAAoB,QAEf,sBAAkB;AACzB,SAASC,oBAAoB,QAAQ,2CAA0C;AAC/E,SAASC,+BAA+B,QAAQ,0BAAsB;AACtE,SACEC,yBAAyB,EACzBC,0BAA0B,QACrB,6BAAyB;AAEhC,SAASC,mBAAmB,QAAQ,oDAAmD;AACvF,SAASC,aAAa,QAAQ,oDAAmD;AAEjF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0CC,GAED,0EAA0E,GAC1E,OAAO,MAAMC,mCAAmC,UAAS;AAEzD,6EAA6E,GAC7E,OAAO,MAAMC,oCAAoC,sBAAqB;AAEtE;;;;;CAKC,GACD,OAAO,MAAMC,2BAA2B,EAAC;AAEzC,2EAA2E,GAC3E,OAAO,MAAMC,+BAA+B,IAAG;AAE/C,6EAA6E,GAC7E,OAAO,MAAMC,6BAA6BX,qBACxCE,iCACD;AAED;;;;;;;;;;;CAWC,GACD,OAAO,SAASU;IACd,MAAMC,MAAMC,QAAQC,GAAG,CAACC,sBAAsB;IAC9C,MAAMC,SAASJ,MAAMK,OAAOL,OAAOK,OAAOC,GAAG;IAC7C,OAAOD,OAAOE,QAAQ,CAACH,WAAWA,UAAU,IAAII,KAAKC,KAAK,CAACL,UAAU;AACvE;AAEA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASM;QACKT;IAAnB,MAAMD,MAAMW,QAAOV,kDAAAA,QAAQC,GAAG,CAACU,kCAAkC,YAA9CX,kDAAkD,IAAIY,IAAI;IAC7E,MAAMT,SAASJ,MAAMK,OAAOL,OAAOK,OAAOC,GAAG;IAC7C,OAAOD,OAAOE,QAAQ,CAACH,WAAWA,SAAS,IAAIA,SAAS;AAC1D;AAmBA;;;;;;CAMC,GACD,OAAO,SAASU,kBACdC,GAAwC;;IAExC,IAAI3B,qBAAqB2B,SAAS,QAAQ,OAAO;IACjD,MAAMC,QAAQL,eAAOI,uBAAAA,IAAKE,QAAQ,mBAAI,IAAIJ,IAAI;IAC9C,MAAMK,UAAUP,gBAAOI,uBAAAA,IAAKI,YAAY,oBAAI,IAAIN,IAAI;IACpD,OAAO;QAAEO,YAAYJ,SAASE,WAAW;IAAK;AAChD;AAEA,OAAO,SAASG,oBACdC,SAAsC,EACtCF,UAAkB,EAClBG,KAAa;IAEb,OAAOD,UACJE,UAAU,CAAC,SACXC,GAAG,CAACL,YACJI,UAAU,CAAC9B,kCACX+B,GAAG,CAACF;AACT;AAEA,OAAO,SAASG,qBACdJ,SAAsC,EACtCK,GAAW;IAEX,OAAOL,UAAUE,UAAU,CAAC7B,mCAAmC8B,GAAG,CAACE;AACrE;AAWA,0EAA0E,GAC1E,OAAO,SAASC,mBACdC,OAAkD,EAClDC,QAAmD,EACnDH,GAAW;cAMGI,WAQiBC,iBACAA;IAb/B,MAAMD,eAAQF,2BAAAA,QAASI,GAAG,CAAC,0BAAW,CAAC;IAIvC,MAAMD,SAAQD,YAAAA,IAAI,CAACJ,IAAI,YAATI,YAAa,CAAC;IAC5B,OAAO;QACL,qEAAqE;QACrE,uBAAuB;QACvBG,gBAAgB3C,2BACdsC,2BAAAA,QAASI,GAAG,CAAC,eACbJ,2BAAAA,QAASI,GAAG,CAAC3C;QAEf6C,sBAAsB9B,QAAO2B,kBAAAA,MAAMI,QAAQ,YAAdJ,kBAAkB;QAC/CK,sBAAsBhC,QAAO2B,kBAAAA,MAAMM,QAAQ,YAAdN,kBAAkB;QAC/CO,iBAAiBlC,gBAAOyB,4BAAAA,SAAUG,GAAG,CAAC,iCAAiB;QACvDO,gBAAgBC,QAAQX,4BAAAA,SAAUG,GAAG,CAAC;IACxC;AACF;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASS,iBACdC,KAAqB,EACrBC,SAKI,CAAC,CAAC;QAEgBA,uBACCA,wBACEA,0BAEvBA;IAJF,MAAMC,iBAAgBD,wBAAAA,OAAOC,aAAa,YAApBD,wBAAwB7C;IAC9C,MAAM+C,kBAAiBF,yBAAAA,OAAOE,cAAc,YAArBF,yBAAyBhD;IAChD,MAAMmD,oBAAmBH,2BAAAA,OAAOG,gBAAgB,YAAvBH,2BAA2B9C;IACpD,MAAMkD,sBACJJ,6BAAAA,OAAOI,kBAAkB,YAAzBJ,6BAA6BlC;IAC/B,IAAIiC,MAAMR,oBAAoB,IAAIU,eAAe,OAAO;IACxD,IAAIF,MAAMN,oBAAoB,IAAIS,gBAAgB,OAAO;IACzD,IAAIH,MAAMT,cAAc,IAAIa,kBAAkB,OAAO;IACrD,IAAIJ,MAAMH,cAAc,IAAIG,MAAMJ,eAAe,IAAIS,oBAAoB;QACvE,OAAO;IACT;IACA,OAAO;AACT;AAEA;;;;;CAKC,GACD,OAAO,SAASC,4BACd1B,KAAa,EACbI,GAAW;IAEX,MAAMuB,YAAYhE,WAAWgE,SAAS;IACtC,OAAO;QACL3B;QACAa,UAAUc,UAAU;QACpBnB,MAAM;YAAE,CAACJ,IAAI,EAAE;gBAAES,UAAUc,UAAU;YAAG;QAAE;QAC1CC,WAAWjE,WAAWkE,eAAe;IACvC;AACF;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAASC,qBAAqBC,KAKpC;IACC,MAAMJ,YAAYhE,WAAWgE,SAAS;IACtC,MAAME,kBAAkBlE,WAAWkE,eAAe;IAClD,MAAM,EAAEG,UAAU,EAAEC,OAAO,EAAE7B,GAAG,EAAEJ,KAAK,EAAE,GAAG+B;IAC5C,OAAO;QACLzB,SAAS2B,UACL;YACEjC;YACAQ,MAAM;gBAAE,CAACJ,IAAI,EAAE;oBAAEW,UAAUY,UAAU;gBAAG;YAAE;YAC1CC,WAAWC;QACb,IACA;YAAE7B;YAAOgC,YAAYL,UAAUK;YAAaJ,WAAWC;QAAkB;QAC7EtB,UAAU;YACRH;YACA4B,YAAYL,UAAUK;YACtBnB,UAAUc,UAAU;YACpBZ,UAAUY,UAAUM,UAAU,IAAI;YAClCL,WAAWC;QACb;IACF;AACF;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,eAAeK,0BACpBnC,SAAsC,EACtCK,GAAW;IAEX,MAAM+B,aAAahD;IACnB,MAAMiD,MAAMjC,qBAAqBJ,WAAWK;IAC5C,MAAMiC,UAAU,MAAMtC,UAAUuC,cAAc,CAAC,OAAOC;YAE1BC;QAD1B,MAAMA,WAAW,MAAMD,GAAG7B,GAAG,CAAC0B;QAC9B,MAAMJ,aAAalD,QAAO0D,gBAAAA,SAAS9B,GAAG,CAAC,yBAAb8B,gBAA8B;QACxD,MAAMC,QACJ,CAACD,SAAS9B,GAAG,CAAC,gBACdsB,cAAcG,aAAa7D;QAC7B,MAAMoE,QAAQ,CAACF,SAAS9B,GAAG,CAAC,eAAesB,cAAcG;QACzD,IAAIM,SAASC,OAAO;YAClBH,GAAGI,GAAG,CACJP,KACA,aACMK,QAAQ;gBAAEG,WAAWjF,WAAWkE,eAAe;YAAG,IAAI,CAAC,GACvDa,QAAQ;gBAAEG,UAAUlF,WAAWkE,eAAe;YAAG,IAAI,CAAC,IAE5D;gBAAEiB,OAAO;YAAK;QAElB;QACA,OAAO;YAAEC,SAASN;YAAOO,QAAQN;YAAOV;QAAW;IACrD;IACA,IAAI,CAACK,QAAQU,OAAO,IAAI,CAACV,QAAQW,MAAM,EAAE,OAAOX;IAEhD,MAAMY,SAAS,CAAC,CAAC,EAAEZ,QAAQL,UAAU,CAACkB,OAAO,CAAC,GAAG,SAAS,EAAEf,WAAWe,OAAO,CAAC,GAAG,QAAQ,CAAC;IAC3F,IAAIb,QAAQW,MAAM,EAAE;QAClB,MAAM9E,cAAc6B,WAAW;YAC7BoD,UAAU;YACVC,QAAQ;YACRC,QAAQ,GAAGjF,kCAAkC,CAAC,EAAEgC,KAAK;YACrDkD,OAAO;gBAAElD;gBAAK4B,YAAYK,QAAQL,UAAU;gBAAEG;YAAW;YACzDoB,IAAI5F,WAAWkE,eAAe;QAChC,GACG2B,KAAK,CAAC,CAACC,QACNC,QAAQD,KAAK,CAAC,oCAAoCA;IAExD;IACA,MAAMxF,oBAAoB;QACxB0F,SAAStB,QAAQW,MAAM,GACnB,CAAC,8BAA8B,EAAE5C,IAAI,EAAE,EAAE6C,QAAQ,GACjD,CAAC,yCAAyC,EAAE7C,IAAI,GAAG,EAAE6C,QAAQ;QACjEW,MAAMvB,QAAQW,MAAM,GAChB,CAAC,uBAAuB,EAAE5C,IAAI,eAAe,EAAE6C,OAAO,CAAC,CAAC,GACxD,mEACA,wEACA,uEACA,qEACA,sEACA,cACA,CAAC,uBAAuB,EAAE7C,IAAI,aAAa,EAAE6C,OAAO,CAAC,CAAC,GACtD,8DACA,iEACA,iEACA;QACJY,SAAS;IACX;IACA,OAAOxB;AACT;AAeA,OAAO,eAAeyB,sBACpB/D,SAAsC,EACtCK,GAAW;QAIeoC,eAIPA,gBACAA;IAPnB,MAAMA,WAAW,MAAMrC,qBAAqBJ,WAAWK,KAAKM,GAAG;IAC/D,MAAMyB,aAAahD;IACnB,MAAM6C,aAAalD,QAAO0D,gBAAAA,SAAS9B,GAAG,CAAC,yBAAb8B,gBAA8B;IACxD,OAAO;QACLpC;QACA4B;QACAnB,UAAU/B,QAAO0D,iBAAAA,SAAS9B,GAAG,CAAC,uBAAb8B,iBAA4B;QAC7CzB,UAAUjC,QAAO0D,iBAAAA,SAAS9B,GAAG,CAAC,uBAAb8B,iBAA4B;QAC7CL;QACAY,SAAS7B,QAAQsB,SAAS9B,GAAG,CAAC;QAC9B,oEAAoE;QACpE,qEAAqE;QACrE,uDAAuD;QACvDsC,QAAQ9B,QAAQsB,SAAS9B,GAAG,CAAC,gBAAgBsB,cAAcG;IAC7D;AACF"}
|
|
@@ -0,0 +1,100 @@
|
|
|
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
|
+
*/
|
|
17
|
+
import { type AssistCreditReturnOutcome } from './assist-credit-returns-write';
|
|
18
|
+
import type { FreeAssistAccount } from './assist-free-taste';
|
|
19
|
+
/**
|
|
20
|
+
* A job that failed on our side gives back what it spent (AGL-3594).
|
|
21
|
+
*
|
|
22
|
+
* "On our side" is the machine's to say, and it says it for: a plan the plan
|
|
23
|
+
* rules still refused after their re-ask, a provider or model error, a step
|
|
24
|
+
* the machine gave up on after its attempts (a timeout or a lease that kept
|
|
25
|
+
* expiring), a step that failed of its own accord, and a step no runner could
|
|
26
|
+
* take. Never for a person canceling — they pay for what ran until they did —
|
|
27
|
+
* nor for a model declining the brief, which the Free taste already meters as
|
|
28
|
+
* a refusal and the refusal pause bounds.
|
|
29
|
+
*
|
|
30
|
+
* WHAT IS GIVEN BACK. A job that delivered nothing gives back everything it
|
|
31
|
+
* spent and has not already given back. A job that delivered drafts and then
|
|
32
|
+
* failed keeps them and pays for them: only the failing step's own spend goes
|
|
33
|
+
* back. The site's listing proposal is not a delivery — it is derived from the
|
|
34
|
+
* answers, costs nothing, and rides out with the first unit.
|
|
35
|
+
*
|
|
36
|
+
* HOW. Through the one give-back writer (`returnAssistCredits`, AGL-3595), to
|
|
37
|
+
* the workspace's band and, on the Free taste, the owner's allowance — the
|
|
38
|
+
* meters the spend was charged to — recorded as `system:job-refund`. The
|
|
39
|
+
* tokens stay on the bill and the signal; the give-back nets them out of what
|
|
40
|
+
* every reader calls "used".
|
|
41
|
+
*
|
|
42
|
+
* BOUNDED. The writer never returns more than a meter's month used. On top
|
|
43
|
+
* of it, at most `AI_JOB_REFUNDS_PER_DAY` automatic give-backs an account a
|
|
44
|
+
* UTC day (the workspace's own month where it names no owner), counted off
|
|
45
|
+
* the give-backs the month already records, so a brief that keeps failing
|
|
46
|
+
* cannot keep spending for free. Past it, the failure is metered as it always
|
|
47
|
+
* was.
|
|
48
|
+
*/
|
|
49
|
+
/** Automatic give-backs an account may have a UTC day. */
|
|
50
|
+
export declare const AI_JOB_REFUNDS_PER_DAY = 3;
|
|
51
|
+
/** The `source` a job's give-back is recorded under. */
|
|
52
|
+
export declare const AI_JOB_REFUND_SOURCE = "job-refund";
|
|
53
|
+
/** Who gives it back: the platform, not a member of staff. */
|
|
54
|
+
export declare const AI_JOB_REFUND_ACTOR = "system:job-refund";
|
|
55
|
+
/** Why a job's credits went back, as the job records it. */
|
|
56
|
+
export type AiJobRefundReason = 'plan-refused' | 'provider' | 'timeout' | 'step-failure' | 'unavailable';
|
|
57
|
+
/**
|
|
58
|
+
* The give-back's key. Per job and per give-back on it (`ordinal`, the job's
|
|
59
|
+
* count of give-backs so far), so one failure gives back once however often
|
|
60
|
+
* it is handled, and a later failure of the same job — a plan tried again and
|
|
61
|
+
* refused again — is a give-back of its own. The day leads, so a day's
|
|
62
|
+
* give-backs are counted off the month's keys. The writer holds a key to
|
|
63
|
+
* `[A-Za-z0-9_-]{8,100}`; the job id is cut to fit.
|
|
64
|
+
*/
|
|
65
|
+
export declare function aiJobRefundKey(input: {
|
|
66
|
+
day: string;
|
|
67
|
+
jobId: string;
|
|
68
|
+
ordinal: number;
|
|
69
|
+
}): string;
|
|
70
|
+
/** How many give-backs a meter's month already records for `day`. */
|
|
71
|
+
export declare function aiJobRefundsOn(returns: unknown, day: string): number;
|
|
72
|
+
/**
|
|
73
|
+
* What a failure gives back: everything the job spent and has not already
|
|
74
|
+
* given back when it delivered nothing, else the failing step's own spend.
|
|
75
|
+
*/
|
|
76
|
+
export declare function aiJobRefundCredits(input: {
|
|
77
|
+
delivered: boolean;
|
|
78
|
+
jobCredits: number;
|
|
79
|
+
stepCredits: number;
|
|
80
|
+
alreadyRefunded: number;
|
|
81
|
+
}): number;
|
|
82
|
+
export type AiJobRefundOutcome = AssistCreditReturnOutcome | {
|
|
83
|
+
status: 'bounded';
|
|
84
|
+
lines: [];
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Give a failed job's credits back to the meters it was charged to, or say
|
|
88
|
+
* why not.
|
|
89
|
+
*/
|
|
90
|
+
export declare function refundJobCredits(firestore: FirebaseFirestore.Firestore, input: {
|
|
91
|
+
orgId: string;
|
|
92
|
+
/** The Free taste's attribution, or `null` on a paid workspace. */
|
|
93
|
+
free: FreeAssistAccount | null;
|
|
94
|
+
jobId: string;
|
|
95
|
+
ordinal: number;
|
|
96
|
+
credits: number;
|
|
97
|
+
month: string;
|
|
98
|
+
day: string;
|
|
99
|
+
reason: AiJobRefundReason;
|
|
100
|
+
}): Promise<AiJobRefundOutcome>;
|
|
@@ -0,0 +1,109 @@
|
|
|
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_CREDIT_RETURNS_FIELD } from "./assist-credit-returns.js";
|
|
17
|
+
import { accountCreditMeter, returnAssistCredits, workspaceCreditMeter } from "./assist-credit-returns-write.js";
|
|
18
|
+
/**
|
|
19
|
+
* A job that failed on our side gives back what it spent (AGL-3594).
|
|
20
|
+
*
|
|
21
|
+
* "On our side" is the machine's to say, and it says it for: a plan the plan
|
|
22
|
+
* rules still refused after their re-ask, a provider or model error, a step
|
|
23
|
+
* the machine gave up on after its attempts (a timeout or a lease that kept
|
|
24
|
+
* expiring), a step that failed of its own accord, and a step no runner could
|
|
25
|
+
* take. Never for a person canceling — they pay for what ran until they did —
|
|
26
|
+
* nor for a model declining the brief, which the Free taste already meters as
|
|
27
|
+
* a refusal and the refusal pause bounds.
|
|
28
|
+
*
|
|
29
|
+
* WHAT IS GIVEN BACK. A job that delivered nothing gives back everything it
|
|
30
|
+
* spent and has not already given back. A job that delivered drafts and then
|
|
31
|
+
* failed keeps them and pays for them: only the failing step's own spend goes
|
|
32
|
+
* back. The site's listing proposal is not a delivery — it is derived from the
|
|
33
|
+
* answers, costs nothing, and rides out with the first unit.
|
|
34
|
+
*
|
|
35
|
+
* HOW. Through the one give-back writer (`returnAssistCredits`, AGL-3595), to
|
|
36
|
+
* the workspace's band and, on the Free taste, the owner's allowance — the
|
|
37
|
+
* meters the spend was charged to — recorded as `system:job-refund`. The
|
|
38
|
+
* tokens stay on the bill and the signal; the give-back nets them out of what
|
|
39
|
+
* every reader calls "used".
|
|
40
|
+
*
|
|
41
|
+
* BOUNDED. The writer never returns more than a meter's month used. On top
|
|
42
|
+
* of it, at most `AI_JOB_REFUNDS_PER_DAY` automatic give-backs an account a
|
|
43
|
+
* UTC day (the workspace's own month where it names no owner), counted off
|
|
44
|
+
* the give-backs the month already records, so a brief that keeps failing
|
|
45
|
+
* cannot keep spending for free. Past it, the failure is metered as it always
|
|
46
|
+
* was.
|
|
47
|
+
*/ /** Automatic give-backs an account may have a UTC day. */ export const AI_JOB_REFUNDS_PER_DAY = 3;
|
|
48
|
+
/** The `source` a job's give-back is recorded under. */ export const AI_JOB_REFUND_SOURCE = 'job-refund';
|
|
49
|
+
/** Who gives it back: the platform, not a member of staff. */ export const AI_JOB_REFUND_ACTOR = `system:${AI_JOB_REFUND_SOURCE}`;
|
|
50
|
+
/**
|
|
51
|
+
* The give-back's key. Per job and per give-back on it (`ordinal`, the job's
|
|
52
|
+
* count of give-backs so far), so one failure gives back once however often
|
|
53
|
+
* it is handled, and a later failure of the same job — a plan tried again and
|
|
54
|
+
* refused again — is a give-back of its own. The day leads, so a day's
|
|
55
|
+
* give-backs are counted off the month's keys. The writer holds a key to
|
|
56
|
+
* `[A-Za-z0-9_-]{8,100}`; the job id is cut to fit.
|
|
57
|
+
*/ export function aiJobRefundKey(input) {
|
|
58
|
+
const job = input.jobId.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 48);
|
|
59
|
+
return `${AI_JOB_REFUND_SOURCE}-${input.day.replace(/-/g, '')}-${job}-${Math.max(0, Math.floor(input.ordinal))}`;
|
|
60
|
+
}
|
|
61
|
+
/** How many give-backs a meter's month already records for `day`. */ export function aiJobRefundsOn(returns, day) {
|
|
62
|
+
if (!returns || typeof returns !== 'object') return 0;
|
|
63
|
+
const prefix = `${AI_JOB_REFUND_SOURCE}-${day.replace(/-/g, '')}-`;
|
|
64
|
+
return Object.keys(returns).filter((key)=>key.startsWith(prefix)).length;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* What a failure gives back: everything the job spent and has not already
|
|
68
|
+
* given back when it delivered nothing, else the failing step's own spend.
|
|
69
|
+
*/ export function aiJobRefundCredits(input) {
|
|
70
|
+
const step = Math.max(0, Math.floor(input.stepCredits));
|
|
71
|
+
if (input.delivered) return step;
|
|
72
|
+
return Math.max(0, Math.floor(input.jobCredits) - Math.max(0, Math.floor(input.alreadyRefunded)));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Give a failed job's credits back to the meters it was charged to, or say
|
|
76
|
+
* why not.
|
|
77
|
+
*/ export async function refundJobCredits(firestore, input) {
|
|
78
|
+
var _input_free;
|
|
79
|
+
const workspace = workspaceCreditMeter(firestore, input.orgId, input.month);
|
|
80
|
+
const account = ((_input_free = input.free) == null ? void 0 : _input_free.accountUid) ? accountCreditMeter(firestore, input.free.accountUid, input.month) : null;
|
|
81
|
+
const counter = await (account != null ? account : workspace).ref.get();
|
|
82
|
+
if (aiJobRefundsOn(counter.get(ASSIST_CREDIT_RETURNS_FIELD), input.day) >= AI_JOB_REFUNDS_PER_DAY) {
|
|
83
|
+
return {
|
|
84
|
+
status: 'bounded',
|
|
85
|
+
lines: []
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return returnAssistCredits(firestore, {
|
|
89
|
+
month: input.month,
|
|
90
|
+
meters: account ? [
|
|
91
|
+
workspace,
|
|
92
|
+
account
|
|
93
|
+
] : [
|
|
94
|
+
workspace
|
|
95
|
+
],
|
|
96
|
+
credits: input.credits,
|
|
97
|
+
key: aiJobRefundKey({
|
|
98
|
+
day: input.day,
|
|
99
|
+
jobId: input.jobId,
|
|
100
|
+
ordinal: input.ordinal
|
|
101
|
+
}),
|
|
102
|
+
reason: `An AI job failed on our side (${input.reason})`,
|
|
103
|
+
actorUid: AI_JOB_REFUND_ACTOR,
|
|
104
|
+
source: AI_JOB_REFUND_SOURCE,
|
|
105
|
+
jobId: input.jobId
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
//# sourceMappingURL=assist-job-refund.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/assist-job-refund.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 { ASSIST_CREDIT_RETURNS_FIELD } from './assist-credit-returns'\nimport {\n accountCreditMeter,\n returnAssistCredits,\n workspaceCreditMeter,\n type AssistCreditReturnOutcome,\n} from './assist-credit-returns-write'\nimport type { FreeAssistAccount } from './assist-free-taste'\n\n/**\n * A job that failed on our side gives back what it spent (AGL-3594).\n *\n * \"On our side\" is the machine's to say, and it says it for: a plan the plan\n * rules still refused after their re-ask, a provider or model error, a step\n * the machine gave up on after its attempts (a timeout or a lease that kept\n * expiring), a step that failed of its own accord, and a step no runner could\n * take. Never for a person canceling — they pay for what ran until they did —\n * nor for a model declining the brief, which the Free taste already meters as\n * a refusal and the refusal pause bounds.\n *\n * WHAT IS GIVEN BACK. A job that delivered nothing gives back everything it\n * spent and has not already given back. A job that delivered drafts and then\n * failed keeps them and pays for them: only the failing step's own spend goes\n * back. The site's listing proposal is not a delivery — it is derived from the\n * answers, costs nothing, and rides out with the first unit.\n *\n * HOW. Through the one give-back writer (`returnAssistCredits`, AGL-3595), to\n * the workspace's band and, on the Free taste, the owner's allowance — the\n * meters the spend was charged to — recorded as `system:job-refund`. The\n * tokens stay on the bill and the signal; the give-back nets them out of what\n * every reader calls \"used\".\n *\n * BOUNDED. The writer never returns more than a meter's month used. On top\n * of it, at most `AI_JOB_REFUNDS_PER_DAY` automatic give-backs an account a\n * UTC day (the workspace's own month where it names no owner), counted off\n * the give-backs the month already records, so a brief that keeps failing\n * cannot keep spending for free. Past it, the failure is metered as it always\n * was.\n */\n\n/** Automatic give-backs an account may have a UTC day. */\nexport const AI_JOB_REFUNDS_PER_DAY = 3\n\n/** The `source` a job's give-back is recorded under. */\nexport const AI_JOB_REFUND_SOURCE = 'job-refund'\n\n/** Who gives it back: the platform, not a member of staff. */\nexport const AI_JOB_REFUND_ACTOR = `system:${AI_JOB_REFUND_SOURCE}`\n\n/** Why a job's credits went back, as the job records it. */\nexport type AiJobRefundReason =\n | 'plan-refused'\n | 'provider'\n | 'timeout'\n | 'step-failure'\n | 'unavailable'\n\n/**\n * The give-back's key. Per job and per give-back on it (`ordinal`, the job's\n * count of give-backs so far), so one failure gives back once however often\n * it is handled, and a later failure of the same job — a plan tried again and\n * refused again — is a give-back of its own. The day leads, so a day's\n * give-backs are counted off the month's keys. The writer holds a key to\n * `[A-Za-z0-9_-]{8,100}`; the job id is cut to fit.\n */\nexport function aiJobRefundKey(input: { day: string; jobId: string; ordinal: number }): string {\n const job = input.jobId.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 48)\n return `${AI_JOB_REFUND_SOURCE}-${input.day.replace(/-/g, '')}-${job}-${Math.max(0, Math.floor(input.ordinal))}`\n}\n\n/** How many give-backs a meter's month already records for `day`. */\nexport function aiJobRefundsOn(returns: unknown, day: string): number {\n if (!returns || typeof returns !== 'object') return 0\n const prefix = `${AI_JOB_REFUND_SOURCE}-${day.replace(/-/g, '')}-`\n return Object.keys(returns).filter((key) => key.startsWith(prefix)).length\n}\n\n/**\n * What a failure gives back: everything the job spent and has not already\n * given back when it delivered nothing, else the failing step's own spend.\n */\nexport function aiJobRefundCredits(input: {\n delivered: boolean\n jobCredits: number\n stepCredits: number\n alreadyRefunded: number\n}): number {\n const step = Math.max(0, Math.floor(input.stepCredits))\n if (input.delivered) return step\n return Math.max(0, Math.floor(input.jobCredits) - Math.max(0, Math.floor(input.alreadyRefunded)))\n}\n\nexport type AiJobRefundOutcome = AssistCreditReturnOutcome | { status: 'bounded'; lines: [] }\n\n/**\n * Give a failed job's credits back to the meters it was charged to, or say\n * why not.\n */\nexport async function refundJobCredits(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n /** The Free taste's attribution, or `null` on a paid workspace. */\n free: FreeAssistAccount | null\n jobId: string\n ordinal: number\n credits: number\n month: string\n day: string\n reason: AiJobRefundReason\n },\n): Promise<AiJobRefundOutcome> {\n const workspace = workspaceCreditMeter(firestore, input.orgId, input.month)\n const account = input.free?.accountUid\n ? accountCreditMeter(firestore, input.free.accountUid, input.month)\n : null\n const counter = await (account ?? workspace).ref.get()\n if (aiJobRefundsOn(counter.get(ASSIST_CREDIT_RETURNS_FIELD), input.day) >= AI_JOB_REFUNDS_PER_DAY) {\n return { status: 'bounded', lines: [] }\n }\n return returnAssistCredits(firestore, {\n month: input.month,\n meters: account ? [workspace, account] : [workspace],\n credits: input.credits,\n key: aiJobRefundKey({ day: input.day, jobId: input.jobId, ordinal: input.ordinal }),\n reason: `An AI job failed on our side (${input.reason})`,\n actorUid: AI_JOB_REFUND_ACTOR,\n source: AI_JOB_REFUND_SOURCE,\n jobId: input.jobId,\n })\n}\n"],"names":["ASSIST_CREDIT_RETURNS_FIELD","accountCreditMeter","returnAssistCredits","workspaceCreditMeter","AI_JOB_REFUNDS_PER_DAY","AI_JOB_REFUND_SOURCE","AI_JOB_REFUND_ACTOR","aiJobRefundKey","input","job","jobId","replace","slice","day","Math","max","floor","ordinal","aiJobRefundsOn","returns","prefix","Object","keys","filter","key","startsWith","length","aiJobRefundCredits","step","stepCredits","delivered","jobCredits","alreadyRefunded","refundJobCredits","firestore","workspace","orgId","month","account","free","accountUid","counter","ref","get","status","lines","meters","credits","reason","actorUid","source"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,2BAA2B,QAAQ,6BAAyB;AACrE,SACEC,kBAAkB,EAClBC,mBAAmB,EACnBC,oBAAoB,QAEf,mCAA+B;AAGtC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6BC,GAED,wDAAwD,GACxD,OAAO,MAAMC,yBAAyB,EAAC;AAEvC,sDAAsD,GACtD,OAAO,MAAMC,uBAAuB,aAAY;AAEhD,4DAA4D,GAC5D,OAAO,MAAMC,sBAAsB,CAAC,OAAO,EAAED,sBAAsB,CAAA;AAUnE;;;;;;;CAOC,GACD,OAAO,SAASE,eAAeC,KAAsD;IACnF,MAAMC,MAAMD,MAAME,KAAK,CAACC,OAAO,CAAC,mBAAmB,IAAIC,KAAK,CAAC,GAAG;IAChE,OAAO,GAAGP,qBAAqB,CAAC,EAAEG,MAAMK,GAAG,CAACF,OAAO,CAAC,MAAM,IAAI,CAAC,EAAEF,IAAI,CAAC,EAAEK,KAAKC,GAAG,CAAC,GAAGD,KAAKE,KAAK,CAACR,MAAMS,OAAO,IAAI;AAClH;AAEA,mEAAmE,GACnE,OAAO,SAASC,eAAeC,OAAgB,EAAEN,GAAW;IAC1D,IAAI,CAACM,WAAW,OAAOA,YAAY,UAAU,OAAO;IACpD,MAAMC,SAAS,GAAGf,qBAAqB,CAAC,EAAEQ,IAAIF,OAAO,CAAC,MAAM,IAAI,CAAC,CAAC;IAClE,OAAOU,OAAOC,IAAI,CAACH,SAASI,MAAM,CAAC,CAACC,MAAQA,IAAIC,UAAU,CAACL,SAASM,MAAM;AAC5E;AAEA;;;CAGC,GACD,OAAO,SAASC,mBAAmBnB,KAKlC;IACC,MAAMoB,OAAOd,KAAKC,GAAG,CAAC,GAAGD,KAAKE,KAAK,CAACR,MAAMqB,WAAW;IACrD,IAAIrB,MAAMsB,SAAS,EAAE,OAAOF;IAC5B,OAAOd,KAAKC,GAAG,CAAC,GAAGD,KAAKE,KAAK,CAACR,MAAMuB,UAAU,IAAIjB,KAAKC,GAAG,CAAC,GAAGD,KAAKE,KAAK,CAACR,MAAMwB,eAAe;AAChG;AAIA;;;CAGC,GACD,OAAO,eAAeC,iBACpBC,SAAsC,EACtC1B,KAUC;QAGeA;IADhB,MAAM2B,YAAYhC,qBAAqB+B,WAAW1B,MAAM4B,KAAK,EAAE5B,MAAM6B,KAAK;IAC1E,MAAMC,UAAU9B,EAAAA,cAAAA,MAAM+B,IAAI,qBAAV/B,YAAYgC,UAAU,IAClCvC,mBAAmBiC,WAAW1B,MAAM+B,IAAI,CAACC,UAAU,EAAEhC,MAAM6B,KAAK,IAChE;IACJ,MAAMI,UAAU,MAAM,CAACH,kBAAAA,UAAWH,WAAWO,GAAG,CAACC,GAAG;IACpD,IAAIzB,eAAeuB,QAAQE,GAAG,CAAC3C,8BAA8BQ,MAAMK,GAAG,KAAKT,wBAAwB;QACjG,OAAO;YAAEwC,QAAQ;YAAWC,OAAO,EAAE;QAAC;IACxC;IACA,OAAO3C,oBAAoBgC,WAAW;QACpCG,OAAO7B,MAAM6B,KAAK;QAClBS,QAAQR,UAAU;YAACH;YAAWG;SAAQ,GAAG;YAACH;SAAU;QACpDY,SAASvC,MAAMuC,OAAO;QACtBvB,KAAKjB,eAAe;YAAEM,KAAKL,MAAMK,GAAG;YAAEH,OAAOF,MAAME,KAAK;YAAEO,SAAST,MAAMS,OAAO;QAAC;QACjF+B,QAAQ,CAAC,8BAA8B,EAAExC,MAAMwC,MAAM,CAAC,CAAC,CAAC;QACxDC,UAAU3C;QACV4C,QAAQ7C;QACRK,OAAOF,MAAME,KAAK;IACpB;AACF"}
|
|
@@ -39,6 +39,7 @@ import { aiUsageKindFromRoute } from "../model/ai-usage-by-user.js";
|
|
|
39
39
|
// The ceiling composition lives beside the one other reader it has, the staff
|
|
40
40
|
// refusal alert, in a module that writes nothing; the reservation's importers
|
|
41
41
|
// keep reaching it here.
|
|
42
|
+
import { ASSIST_RETURNED_USD_FIELD, assistSpendAfterReturnsUsd } from "./assist-credit-returns.js";
|
|
42
43
|
export { assistMonthlyCeilingUsd } from "./assist-ceiling.js";
|
|
43
44
|
/**
|
|
44
45
|
* Aglyn Assist metering + the data loop (AGL-1860, phase 1).
|
|
@@ -389,7 +390,7 @@ async function reserveInTransaction(firestore, orgId, entitled, now, org, subjec
|
|
|
389
390
|
// with no invoice to pay. See `ai-overage-cutover.ts`.
|
|
390
391
|
const guardsOverage = !bandRefuses && budgetUsd !== null && resolveAssistOverageRateUsdPer1k(org) !== null && aiOverageGuardsApply(month);
|
|
391
392
|
return firestore.runTransaction(async (tx)=>{
|
|
392
|
-
var _snapshot_get,
|
|
393
|
+
var _snapshot_get, _ref, _standingSnapshot_data;
|
|
393
394
|
// Every read before any write — Firestore requires that ordering, and it
|
|
394
395
|
// is also what makes check-and-increment a single atomic step.
|
|
395
396
|
const snapshot = await tx.get(gateRef);
|
|
@@ -410,7 +411,9 @@ async function reserveInTransaction(firestore, orgId, entitled, now, org, subjec
|
|
|
410
411
|
const accountSnapshot = accountRef ? await tx.get(accountRef) : null;
|
|
411
412
|
const platformSnapshot = free ? await tx.get(platformFreeSpendRef(firestore, day)) : null;
|
|
412
413
|
const used = Number((_snapshot_get = snapshot.get(gateField)) != null ? _snapshot_get : 0);
|
|
413
|
-
|
|
414
|
+
// Net of any credits given back this month (AGL-3595), so a give-back
|
|
415
|
+
// re-opens the band it was meant to re-open.
|
|
416
|
+
const costUsd = monthlySnapshot ? assistSpendAfterReturnsUsd(monthlySnapshot.get('estCostUsd'), monthlySnapshot.get(ASSIST_RETURNED_USD_FIELD)) : null;
|
|
414
417
|
if (!(used < limit)) {
|
|
415
418
|
recordAssistRefusal(firestore, orgId, month, 'messages');
|
|
416
419
|
return {
|