@agent-finops/core 0.9.5 → 0.9.7
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 +3 -3
- package/dist/actionPlanner.js +4 -1
- package/dist/activitySnapshot.js +12 -3
- package/dist/analyze.js +59 -18
- package/dist/contextHealth.js +15 -4
- package/dist/cutList.d.ts +23 -0
- package/dist/cutList.js +268 -11
- package/dist/deadContext.js +10 -3
- package/dist/glance.js +10 -6
- package/dist/insights.js +41 -23
- package/dist/localAgentLogs.d.ts +19 -3
- package/dist/localAgentLogs.js +112 -18
- package/dist/modelPricing.d.ts +17 -4
- package/dist/modelPricing.js +114 -22
- package/dist/planMath.js +25 -4
- package/dist/projectIndexStore.d.ts +6 -4
- package/dist/qualitativeIndexCache.d.ts +4 -2
- package/dist/qualitativeIndexCache.js +5 -0
- package/dist/toolInvocations.js +9 -1
- package/dist/untrustedLabel.d.ts +70 -0
- package/dist/untrustedLabel.js +161 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,9 +41,9 @@ keep provenance and confidence labels truthful: reserve `verified` for
|
|
|
41
41
|
official provider-reported financial evidence, keep modeled/local value
|
|
42
42
|
`estimated` or `missing`, and leave unvalidated adapters `untested`.
|
|
43
43
|
|
|
44
|
-
This is the open foundation for aibill's financial-accountability mission.
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
This is the open foundation for aibill's financial-accountability mission. This
|
|
45
|
+
package includes contracts for locally confirmed ownership, local
|
|
46
|
+
self-attested approvals, and opt-in accepted GitHub outcomes. Those are
|
|
47
47
|
not company-wide identity, RBAC, approval routing, invoice reconciliation, or
|
|
48
48
|
verified business ROI.
|
|
49
49
|
|
package/dist/actionPlanner.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { localAgentFormatDescriptor } from "./localAgentFormats/registry.js";
|
|
3
3
|
import { MAX_TOKEN_EXPERIMENT_SESSIONS_PER_PHASE_V0, MAX_WASTE_FINDING_EVIDENCE_REFS_V0, TOKEN_REDUCTION_EXPERIMENT_V0_KIND, TOKEN_REDUCTION_EXPERIMENT_V0_VERSION, WASTE_FINDING_V0_KIND, WASTE_FINDING_V0_VERSION, createActionVerificationReference, createTokenReductionExperimentV0, createWasteFindingV0 } from "./actionVerification.js";
|
|
4
|
+
import { safeUntrustedLabel, WITHHELD_FILE_LABEL } from "./untrustedLabel.js";
|
|
4
5
|
const MINIMUM_SESSIONS = 3;
|
|
5
6
|
const CONTEXT_RATIO_THRESHOLD = 1.5;
|
|
6
7
|
const FRESH_MS = 72 * 60 * 60 * 1_000;
|
|
@@ -196,7 +197,9 @@ export function resolveWasteFindingTargetV0(input) {
|
|
|
196
197
|
status: "resolved",
|
|
197
198
|
kind: "repeated_read_file",
|
|
198
199
|
ref: finding.target.ref,
|
|
199
|
-
|
|
200
|
+
// Already neutralized upstream; re-applied because a resolved target
|
|
201
|
+
// is written into the Apply artifact an agent reads.
|
|
202
|
+
file: safeUntrustedLabel(match.file, WITHHELD_FILE_LABEL),
|
|
200
203
|
readCount: match.readCount,
|
|
201
204
|
localOnly: true
|
|
202
205
|
}
|
package/dist/activitySnapshot.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { aggregateCalls, dedupeCumulativeSessionCalls } from "./localAgentLogs.js";
|
|
3
3
|
import { localAgentFormatDescriptors } from "./localAgentFormats/registry.js";
|
|
4
|
-
import { canPriceTokenUsageAtScope,
|
|
4
|
+
import { canPriceTokenUsageAtScope, estimateTokenCostsUsd, PRICING_TABLE_AS_OF } from "./modelPricing.js";
|
|
5
5
|
import { subscriptionPlans } from "./planMath.js";
|
|
6
6
|
import { isBundledSampleUsage } from "./schema.js";
|
|
7
7
|
import { sourceValidationCoverageValues } from "./sourceStatus.js";
|
|
@@ -937,13 +937,22 @@ function apiEquivalentObservations(records, calls, trustedProviderIds) {
|
|
|
937
937
|
}
|
|
938
938
|
return observations;
|
|
939
939
|
}
|
|
940
|
+
/**
|
|
941
|
+
* One call's API-equivalent cost with its tier taken from the largest single
|
|
942
|
+
* request it contains, matching the report's aggregation. Weighting a
|
|
943
|
+
* session-cumulative slice at its own cache-inflated prompt would put it on the
|
|
944
|
+
* wrong tier and skew the allocation.
|
|
945
|
+
*/
|
|
946
|
+
function callAmountUsd(call) {
|
|
947
|
+
return estimateTokenCostsUsd(call.model, [call.usage], [call.maxRequestPromptTokens]);
|
|
948
|
+
}
|
|
940
949
|
function allocateAggregateAmount(amountUsd, calls) {
|
|
941
950
|
if (amountUsd === null)
|
|
942
951
|
return calls.map(() => null);
|
|
943
|
-
const priceable = calls.map((call) => canPriceTokenUsageAtScope(call.model, call.usage, call.usageScope === "turn" ? "request" : "aggregate") &&
|
|
952
|
+
const priceable = calls.map((call) => canPriceTokenUsageAtScope(call.model, call.usage, call.usageScope === "turn" ? "request" : "aggregate", call.maxRequestPromptTokens) && callAmountUsd(call) !== undefined);
|
|
944
953
|
if (priceable.some((supported) => !supported))
|
|
945
954
|
return calls.map(() => null);
|
|
946
|
-
const weights = calls.map((call) =>
|
|
955
|
+
const weights = calls.map((call) => callAmountUsd(call) ?? 0);
|
|
947
956
|
const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
|
|
948
957
|
if (totalWeight <= 0) {
|
|
949
958
|
return amountUsd === 0 ? calls.map(() => 0) : calls.map(() => null);
|
package/dist/analyze.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { generateSpendInsights } from "./insights.js";
|
|
2
|
+
import { safeUntrustedLabel, safeUntrustedLabels, WITHHELD_AGENT_LABEL, WITHHELD_ENTITY_LABEL, WITHHELD_OPERATION_LABEL } from "./untrustedLabel.js";
|
|
2
3
|
import { costConfidenceValues, hasModeledWorkloadEvidence, hasPricedEvidence, spendComparisonKey, spendSummarySchema } from "./schema.js";
|
|
3
4
|
const confidenceRank = {
|
|
4
5
|
verified: 0,
|
|
@@ -154,11 +155,27 @@ export function generateWorkflowWatch(records) {
|
|
|
154
155
|
const hasRunLevelEvidence = groupRecords.every(hasModeledWorkloadEvidence);
|
|
155
156
|
const suggestedOptimization = workflowDiagnosticFor(workflowKey, agentId, hasRunLevelEvidence);
|
|
156
157
|
return {
|
|
157
|
-
id
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
158
|
+
// The id is a STRUCTURED field beside the neutralized prose and it is
|
|
159
|
+
// rendered. Slug the neutralized forms, not the raw grouping keys.
|
|
160
|
+
id: slugify([
|
|
161
|
+
"workflow",
|
|
162
|
+
safeEntity(clientId),
|
|
163
|
+
safeEntity(projectId),
|
|
164
|
+
safeWorkflowLabel(workflowKey)
|
|
165
|
+
].join("-")),
|
|
166
|
+
// Neutralized, not raw. These fields ship inside the SpendSummary that
|
|
167
|
+
// the MCP tools hand to an agent, so leaving them raw beside neutralized
|
|
168
|
+
// prose is the same inversion as the repeated-read array: the human sees
|
|
169
|
+
// the redaction and the agent gets the payload.
|
|
170
|
+
//
|
|
171
|
+
// workflowWatchAmount re-matches these against the records to recompute
|
|
172
|
+
// a display amount. An ordinary name is byte-identical, so that match is
|
|
173
|
+
// unaffected; a name that reads like an instruction loses its amount,
|
|
174
|
+
// which is the right way round.
|
|
175
|
+
clientId: safeEntity(clientId),
|
|
176
|
+
projectId: safeEntity(projectId),
|
|
177
|
+
workflowKey: safeWorkflowLabel(workflowKey),
|
|
178
|
+
agentId: safeUntrustedLabel(agentId, WITHHELD_AGENT_LABEL),
|
|
162
179
|
amountUsd,
|
|
163
180
|
shareOfSpend,
|
|
164
181
|
recordCount: groupRecords.length,
|
|
@@ -168,8 +185,8 @@ export function generateWorkflowWatch(records) {
|
|
|
168
185
|
suggestedOptimization,
|
|
169
186
|
applyArtifact: `Before changing this workload: ${suggestedOptimization}`,
|
|
170
187
|
verificationPlan: hasRunLevelEvidence
|
|
171
|
-
? `Reconcile ${workflowKey} to its owner and budget, then define one reversible candidate and compare matched future accepted outcomes plus provider-reported cost.`
|
|
172
|
-
: `Reconcile ${workflowKey} to its owner and budget, then collect call-level workload evidence before modeling or applying a cost change.`
|
|
188
|
+
? `Reconcile ${safeWorkflowLabel(workflowKey)} to its owner and budget, then define one reversible candidate and compare matched future accepted outcomes plus provider-reported cost.`
|
|
189
|
+
: `Reconcile ${safeWorkflowLabel(workflowKey)} to its owner and budget, then collect call-level workload evidence before modeling or applying a cost change.`
|
|
173
190
|
};
|
|
174
191
|
})
|
|
175
192
|
.filter((entry) => entry.amountUsd > 0)
|
|
@@ -186,16 +203,16 @@ export function generateRecommendations(records) {
|
|
|
186
203
|
recommendations.push({
|
|
187
204
|
id: "model-downgrade",
|
|
188
205
|
title: "Review expensive model workloads for downgrade candidates",
|
|
189
|
-
rationale: `${topModel.key} is the largest cost driver in the current local sample.`,
|
|
206
|
+
rationale: `${safeEntity(topModel.key)} is the largest cost driver in the current local sample.`,
|
|
190
207
|
whyItMatters: "Premium model usage tends to become invisible once agents are running in the background. Spend owners need a clear rule for which jobs deserve the expensive model.",
|
|
191
|
-
nextAction: `Audit the top ${topModel.key} operations and move low-risk summarization, extraction, and draft work to a cheaper model tier first.`,
|
|
208
|
+
nextAction: `Audit the top ${safeEntity(topModel.key)} operations and move low-risk summarization, extraction, and draft work to a cheaper model tier first.`,
|
|
192
209
|
priority: "high",
|
|
193
210
|
// The high-level recommendation does not know which model-specific rule
|
|
194
211
|
// will pass quality verification. Dollar math lives in the exact cut
|
|
195
212
|
// candidate; concentration alone earns no flat percentage.
|
|
196
213
|
estimatedImpactUsd: 0,
|
|
197
214
|
confidence: topModel.confidence,
|
|
198
|
-
relatedKeys: [topModel.key]
|
|
215
|
+
relatedKeys: [safeEntity(topModel.key)]
|
|
199
216
|
});
|
|
200
217
|
}
|
|
201
218
|
const highInputTokenRecords = decisionRecords.filter((record) => record.inputTokens >= 100_000);
|
|
@@ -209,7 +226,7 @@ export function generateRecommendations(records) {
|
|
|
209
226
|
priority: "high",
|
|
210
227
|
estimatedImpactUsd: 0,
|
|
211
228
|
confidence: combinedConfidence(highInputTokenRecords.map((record) => record.costConfidence)),
|
|
212
|
-
relatedKeys: unique(highInputTokenRecords.map((record) => record.model))
|
|
229
|
+
relatedKeys: safeUntrustedLabels(unique(highInputTokenRecords.map((record) => record.model)))
|
|
213
230
|
});
|
|
214
231
|
}
|
|
215
232
|
// An operation label alone does not prove identical inputs. Require an
|
|
@@ -229,7 +246,7 @@ export function generateRecommendations(records) {
|
|
|
229
246
|
priority: "medium",
|
|
230
247
|
estimatedImpactUsd: roundMoney(sumRecords(cacheableRecords)),
|
|
231
248
|
confidence: combinedConfidence(cacheableRecords.map((record) => record.costConfidence)),
|
|
232
|
-
relatedKeys: repeatedOperations
|
|
249
|
+
relatedKeys: safeUntrustedLabels(repeatedOperations, WITHHELD_OPERATION_LABEL)
|
|
233
250
|
});
|
|
234
251
|
}
|
|
235
252
|
const agentSpend = breakdown(decisionRecords, (record) => record.agentId);
|
|
@@ -238,13 +255,13 @@ export function generateRecommendations(records) {
|
|
|
238
255
|
recommendations.push({
|
|
239
256
|
id: "agent-caps",
|
|
240
257
|
title: "Confirm the owner and budget for the highest-cost agent",
|
|
241
|
-
rationale: `${topAgent.key} accounts for a material share of sampled usage.`,
|
|
258
|
+
rationale: `${safeEntity(topAgent.key)} accounts for a material share of sampled usage.`,
|
|
242
259
|
whyItMatters: "Concentration is an accountability signal, but it does not by itself prove abnormal behavior or an avoidable dollar amount.",
|
|
243
|
-
nextAction: `Confirm ${topAgent.key}'s owner and approved range, then collect run-level evidence before proposing a warning threshold or hard cap.`,
|
|
260
|
+
nextAction: `Confirm ${safeEntity(topAgent.key)}'s owner and approved range, then collect run-level evidence before proposing a warning threshold or hard cap.`,
|
|
244
261
|
priority: "high",
|
|
245
262
|
estimatedImpactUsd: 0,
|
|
246
263
|
confidence: topAgent.confidence,
|
|
247
|
-
relatedKeys: [topAgent.key]
|
|
264
|
+
relatedKeys: [safeEntity(topAgent.key)]
|
|
248
265
|
});
|
|
249
266
|
}
|
|
250
267
|
const batchableRecords = decisionRecords.filter((record) => record.workloadSemantics?.batchEligible === true &&
|
|
@@ -262,7 +279,7 @@ export function generateRecommendations(records) {
|
|
|
262
279
|
return total + (record.amountUsd ?? 0) * (1 - retained);
|
|
263
280
|
}, 0)),
|
|
264
281
|
confidence: combinedConfidence(batchableRecords.map((record) => record.costConfidence)),
|
|
265
|
-
relatedKeys: unique(batchableRecords.map((record) => record.operation).filter(isPresent))
|
|
282
|
+
relatedKeys: safeUntrustedLabels(unique(batchableRecords.map((record) => record.operation).filter(isPresent)), WITHHELD_OPERATION_LABEL)
|
|
266
283
|
});
|
|
267
284
|
}
|
|
268
285
|
return recommendations;
|
|
@@ -335,9 +352,33 @@ function isLocalAgentRecord(record) {
|
|
|
335
352
|
return record.providerCostType === "local_agent_logs";
|
|
336
353
|
}
|
|
337
354
|
function workflowDiagnosticFor(workflowKey, agentId, hasRunLevelEvidence) {
|
|
355
|
+
const workflow = safeWorkflowLabel(workflowKey);
|
|
356
|
+
const agent = safeUntrustedLabel(agentId, WITHHELD_AGENT_LABEL);
|
|
338
357
|
return hasRunLevelEvidence
|
|
339
|
-
? `Confirm the owner and approved budget for ${
|
|
340
|
-
: `Confirm the owner and approved budget for ${
|
|
358
|
+
? `Confirm the owner and approved budget for ${workflow} (${agent}), reconcile the observed spend, and define one reversible candidate with an accepted-outcome quality bar before approval.`
|
|
359
|
+
: `Confirm the owner and approved budget for ${workflow} (${agent}), reconcile the observed spend, and collect call-level provenance before proposing a reversible optimization.`;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* The workflow key IS `record.operation` — a provider/adapter string, untrusted
|
|
363
|
+
* end to end. It reaches `suggestedOptimization`, `applyArtifact` and
|
|
364
|
+
* `verificationPlan`, and those three are rendered as product prose that the
|
|
365
|
+
* report layer deliberately never blanks. So it is neutralized here, at the
|
|
366
|
+
* interpolation point, exactly as the cut-list builders do.
|
|
367
|
+
*
|
|
368
|
+
* The ENTRY's own identity fields are neutralized too: they travel inside the
|
|
369
|
+
* SpendSummary that the MCP tools return, so a raw field sitting beside
|
|
370
|
+
* neutralized prose hands an agent exactly what the human was protected from.
|
|
371
|
+
*/
|
|
372
|
+
function safeWorkflowLabel(workflowKey) {
|
|
373
|
+
return safeUntrustedLabel(workflowKey, WITHHELD_OPERATION_LABEL);
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* A breakdown key rendered for a HUMAN — a model id, an agent id, an
|
|
377
|
+
* operation label. Display only: the raw key is still what the record filters
|
|
378
|
+
* match on, and rewriting a matching key would empty the cohort.
|
|
379
|
+
*/
|
|
380
|
+
function safeEntity(value) {
|
|
381
|
+
return safeUntrustedLabel(value, WITHHELD_ENTITY_LABEL);
|
|
341
382
|
}
|
|
342
383
|
function slugify(value) {
|
|
343
384
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
package/dist/contextHealth.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { safeUntrustedLabel, WITHHELD_FILE_LABEL, WITHHELD_PROJECT_LABEL } from "./untrustedLabel.js";
|
|
1
2
|
import { loadAgentInventory } from "./agentInventory.js";
|
|
2
3
|
import { computeDeadContext } from "./deadContext.js";
|
|
3
4
|
import { localAgentFormatSupports } from "./localAgentFormats/registry.js";
|
|
@@ -74,7 +75,9 @@ export function buildContextHealth(input = {}) {
|
|
|
74
75
|
if ((contextChurn.repeatedReadEvents ?? 0) > 0) {
|
|
75
76
|
const files = contextChurn.repeatedFiles
|
|
76
77
|
.slice(0, 3)
|
|
77
|
-
|
|
78
|
+
// Basenames, but a basename is still a name the user did not author,
|
|
79
|
+
// and this sentence is returned verbatim by the MCP context-health tool.
|
|
80
|
+
.map((file) => `${safeUntrustedLabel(file.file, WITHHELD_FILE_LABEL)} ×${file.readCount}`)
|
|
78
81
|
.join(", ");
|
|
79
82
|
evidence.push({
|
|
80
83
|
kind: "context_churn",
|
|
@@ -231,6 +234,9 @@ function contextDecision(input) {
|
|
|
231
234
|
const MIN_EXACT_COMPARISONS = 2;
|
|
232
235
|
const MIN_SESSION_TYPE_COMPARISONS = 2;
|
|
233
236
|
const MAX_DISPLAY_RATIO = 20;
|
|
237
|
+
function safeOptionalProject(value) {
|
|
238
|
+
return value === undefined ? undefined : safeUntrustedLabel(value, WITHHELD_PROJECT_LABEL);
|
|
239
|
+
}
|
|
234
240
|
function buildCurrentSession(sessions, now, activeWithinMinutes) {
|
|
235
241
|
const latest = latestContextSession(sessions);
|
|
236
242
|
if (!latest)
|
|
@@ -273,7 +279,9 @@ function buildCurrentSession(sessions, now, activeWithinMinutes) {
|
|
|
273
279
|
return {
|
|
274
280
|
status: ageMs <= activeWithinMinutes * 60_000 ? "active" : "recent",
|
|
275
281
|
agent: latest.agent,
|
|
276
|
-
project: latest.project
|
|
282
|
+
project: latest.project === undefined
|
|
283
|
+
? undefined
|
|
284
|
+
: safeUntrustedLabel(latest.project, WITHHELD_PROJECT_LABEL),
|
|
277
285
|
totalTokens: latest.totalTokens,
|
|
278
286
|
contextTokens: latest.contextTokens,
|
|
279
287
|
usageSource: latest.usageSource,
|
|
@@ -308,7 +316,7 @@ function contextSessions(calls) {
|
|
|
308
316
|
return {
|
|
309
317
|
key,
|
|
310
318
|
agent: latest.agent,
|
|
311
|
-
project: latest.project ?? ordered[0]?.project,
|
|
319
|
+
project: safeOptionalProject(latest.project ?? ordered[0]?.project),
|
|
312
320
|
lastActivityAt: latest.timestamp,
|
|
313
321
|
totalTokens: turnUsage?.totalTokens ?? 0,
|
|
314
322
|
contextTokens: turnUsage?.contextTokens ?? 0,
|
|
@@ -350,8 +358,11 @@ function buildContextChurn(latest, invocations) {
|
|
|
350
358
|
signal.sessionId &&
|
|
351
359
|
latest.key === `${signal.agent}:${signal.sessionId}`))
|
|
352
360
|
: undefined;
|
|
361
|
+
// Neutralized at the source in toolInvocations.ts; re-applied here because a
|
|
362
|
+
// signal can also arrive from a cache written by an earlier version, and
|
|
363
|
+
// because this array is handed to an agent verbatim by the MCP tool.
|
|
353
364
|
const repeatedFiles = (currentSignal?.repeatedFileReads ?? [])
|
|
354
|
-
.map((file) => ({ file: file.name, readCount: file.count }));
|
|
365
|
+
.map((file) => ({ file: safeUntrustedLabel(file.name, WITHHELD_FILE_LABEL), readCount: file.count }));
|
|
355
366
|
return {
|
|
356
367
|
currentSessionEvidence: !latest
|
|
357
368
|
? "no_current_session"
|
package/dist/cutList.d.ts
CHANGED
|
@@ -40,7 +40,30 @@ export type CutAction = {
|
|
|
40
40
|
* by two actions (see {@link buildRecommendedPlan}).
|
|
41
41
|
*/
|
|
42
42
|
recordIds: string[];
|
|
43
|
+
/**
|
|
44
|
+
* Median DAY's summed input+cache tokens for this candidate, computed over
|
|
45
|
+
* calendar days (not over records — a project running two models emits two
|
|
46
|
+
* records per day, and calling that "per day" would halve the number).
|
|
47
|
+
*
|
|
48
|
+
* Exists so a grouped render can tell two members apart by the quantity that
|
|
49
|
+
* explains their dollars instead of by the rounded dollar alone. Optional and
|
|
50
|
+
* absent on candidates with no day-level evidence; every renderer MUST drop
|
|
51
|
+
* the figure rather than print a placeholder.
|
|
52
|
+
*/
|
|
53
|
+
medianDailyInputTokens?: number;
|
|
43
54
|
};
|
|
55
|
+
/**
|
|
56
|
+
* Compact token count for prose: 2,140,000 -> "2.1M", 8,300 -> "8.3K".
|
|
57
|
+
* Lives in core because the cut-list guidance strings are built here, and is
|
|
58
|
+
* re-used by the renderers so one candidate's token magnitude reads the same
|
|
59
|
+
* on every surface.
|
|
60
|
+
*
|
|
61
|
+
* The ladder runs to T. It used to stop at B, so a fleet-scale window printed
|
|
62
|
+
* "4212.7B" — a number the reader has to count digits on to place, from a
|
|
63
|
+
* product whose whole claim is arithmetic you can read at a glance. Past T the
|
|
64
|
+
* mantissa gets thousands separators rather than a fourteenth silent digit.
|
|
65
|
+
*/
|
|
66
|
+
export declare function formatTokenCount(tokens: number): string;
|
|
44
67
|
/**
|
|
45
68
|
* A non-overlapping "recommended plan" plus the leftover overlapping
|
|
46
69
|
* opportunities. The recommended-plan total is the only savings number safe to
|
package/dist/cutList.js
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
import { hasCallLevelProvenance, hasModeledWorkloadEvidence, hasPricedEvidence } from "./schema.js";
|
|
2
2
|
import { localAgentFormatSupports } from "./localAgentFormats/registry.js";
|
|
3
|
+
import { safeUntrustedLabel, WITHHELD_AGENT_LABEL, WITHHELD_MODEL_LABEL, WITHHELD_OPERATION_LABEL, WITHHELD_PROJECT_LABEL } from "./untrustedLabel.js";
|
|
4
|
+
/**
|
|
5
|
+
* Compact token count for prose: 2,140,000 -> "2.1M", 8,300 -> "8.3K".
|
|
6
|
+
* Lives in core because the cut-list guidance strings are built here, and is
|
|
7
|
+
* re-used by the renderers so one candidate's token magnitude reads the same
|
|
8
|
+
* on every surface.
|
|
9
|
+
*
|
|
10
|
+
* The ladder runs to T. It used to stop at B, so a fleet-scale window printed
|
|
11
|
+
* "4212.7B" — a number the reader has to count digits on to place, from a
|
|
12
|
+
* product whose whole claim is arithmetic you can read at a glance. Past T the
|
|
13
|
+
* mantissa gets thousands separators rather than a fourteenth silent digit.
|
|
14
|
+
*/
|
|
15
|
+
export function formatTokenCount(tokens) {
|
|
16
|
+
if (!Number.isFinite(tokens))
|
|
17
|
+
return "0";
|
|
18
|
+
const value = Math.max(0, tokens);
|
|
19
|
+
if (value >= 1_000_000_000_000)
|
|
20
|
+
return `${formatMantissa(value / 1_000_000_000_000)}T`;
|
|
21
|
+
if (value >= 1_000_000_000)
|
|
22
|
+
return `${(value / 1_000_000_000).toFixed(1)}B`;
|
|
23
|
+
if (value >= 1_000_000)
|
|
24
|
+
return `${(value / 1_000_000).toFixed(1)}M`;
|
|
25
|
+
if (value >= 1_000)
|
|
26
|
+
return `${(value / 1_000).toFixed(1)}K`;
|
|
27
|
+
return String(Math.round(value));
|
|
28
|
+
}
|
|
29
|
+
/** Top of the ladder: keep one decimal until the integer part needs commas. */
|
|
30
|
+
function formatMantissa(value) {
|
|
31
|
+
return value >= 1_000
|
|
32
|
+
? Math.round(value).toLocaleString("en-US")
|
|
33
|
+
: value.toFixed(1);
|
|
34
|
+
}
|
|
3
35
|
/**
|
|
4
36
|
* Select a non-overlapping subset of cut actions, highest-savings first. An
|
|
5
37
|
* action is added only if none of its records were already claimed by a
|
|
@@ -126,10 +158,18 @@ function modelDowngradeActions(records) {
|
|
|
126
158
|
const affectedSpendUsd = roundMoney(sumRecords(groupRecords));
|
|
127
159
|
const windowSavings = affectedSpendUsd * (1 - rule.costRetained);
|
|
128
160
|
const monthlySavings = roundMoney(toMonthly(windowSavings, window));
|
|
161
|
+
// Both untrusted. The model gate is anchored but open-ended
|
|
162
|
+
// (`^claude-opus-4(?:[.-].*)?$` accepts anything after the dash), and
|
|
163
|
+
// `downgradeSafeOperation` is a SUBSTRING allowlist — "summary" anywhere
|
|
164
|
+
// in the label passes the whole label through. Neutralize before either
|
|
165
|
+
// reaches a title or an instruction. IDs keep the raw values: an id is
|
|
166
|
+
// identity, not display, and it is rendered through a blanking guard.
|
|
167
|
+
const modelLabel = safeUntrustedLabel(model, WITHHELD_MODEL_LABEL);
|
|
168
|
+
const operationLabel = safeUntrustedLabel(operation, WITHHELD_OPERATION_LABEL);
|
|
129
169
|
actions.push({
|
|
130
170
|
id: `downgrade-${slug(model)}-${slug(operation)}`,
|
|
131
|
-
title: `Move ${
|
|
132
|
-
action: `Route ${groupRecords.length} ${
|
|
171
|
+
title: `Move ${modelLabel} ${operationLabel} calls to ${target}`,
|
|
172
|
+
action: `Route ${groupRecords.length} ${operationLabel} call${groupRecords.length === 1 ? "" : "s"} from ${modelLabel} to ${target} (keep ${modelLabel} only when output is rejected).`,
|
|
133
173
|
estimatedMonthlySavingsUsd: monthlySavings,
|
|
134
174
|
affectedSpendUsd,
|
|
135
175
|
recordCount: groupRecords.length,
|
|
@@ -155,6 +195,30 @@ function contextTrimActions(records) {
|
|
|
155
195
|
: `connected::${operation}`;
|
|
156
196
|
byOperation.set(key, [...(byOperation.get(key) ?? []), record]);
|
|
157
197
|
}
|
|
198
|
+
// ONE denominator, and it is the set the sentence is already talking about:
|
|
199
|
+
// this agent's OWN flagged projects. It is both the share's denominator and
|
|
200
|
+
// the rank clause's population, so the percentage, the rank, and the entry's
|
|
201
|
+
// own dollars two lines below all reconcile, and the members of one fan-out
|
|
202
|
+
// sum to 100%.
|
|
203
|
+
//
|
|
204
|
+
// 0.9.7 shipped three denominators in one sentence — a machine-wide local
|
|
205
|
+
// total for the share, this agent's flagged projects for the rank, and the
|
|
206
|
+
// entry's own total underneath. Every figure was individually true, which is
|
|
207
|
+
// exactly why the sentence read as an arithmetic error. Never reintroduce a
|
|
208
|
+
// denominator the sentence does not name.
|
|
209
|
+
const flaggedSpendByAgent = new Map();
|
|
210
|
+
for (const [key, groupRecords] of byOperation) {
|
|
211
|
+
const [scope, agentId] = key.split("::");
|
|
212
|
+
if (scope !== "local" || !agentId)
|
|
213
|
+
continue;
|
|
214
|
+
// roundMoney, matching `affectedSpendUsd` exactly: comparing a rounded
|
|
215
|
+
// group against unrounded peers made a candidate outrank ITSELF and
|
|
216
|
+
// report "rank 2 of 8" for the largest project on the list.
|
|
217
|
+
flaggedSpendByAgent.set(agentId, [
|
|
218
|
+
...(flaggedSpendByAgent.get(agentId) ?? []),
|
|
219
|
+
roundMoney(sumRecords(groupRecords))
|
|
220
|
+
]);
|
|
221
|
+
}
|
|
158
222
|
const actions = [];
|
|
159
223
|
for (const [key, groupRecords] of byOperation) {
|
|
160
224
|
const affectedSpendUsd = roundMoney(sumRecords(groupRecords));
|
|
@@ -164,7 +228,26 @@ function contextTrimActions(records) {
|
|
|
164
228
|
: key.replace(/^connected::/, "");
|
|
165
229
|
const count = groupRecords.length;
|
|
166
230
|
const agent = groupRecords[0]?.agentId ?? "coding-agent";
|
|
231
|
+
// `agent` stays raw as the map key and the id slug; only the DISPLAY
|
|
232
|
+
// form is neutralized. Bounded to the adapter enum for local rows in
|
|
233
|
+
// practice, but nothing in the type system enforces that.
|
|
234
|
+
const agentLabel = safeUntrustedLabel(agent, WITHHELD_AGENT_LABEL);
|
|
167
235
|
const project = groupRecords[0]?.projectId;
|
|
236
|
+
const evidence = sessionAggregates ? dailyContextEvidence(groupRecords) : null;
|
|
237
|
+
const flaggedSpend = flaggedSpendByAgent.get(agent) ?? [];
|
|
238
|
+
// The project label is UNTRUSTED — it is a directory name off the user's
|
|
239
|
+
// disk. Neutralize it ONCE, here, so the title, the guidance, and every
|
|
240
|
+
// surface that re-derives a label from the title all carry the same
|
|
241
|
+
// already-safe text and cannot disagree about it.
|
|
242
|
+
const projectLabel = project
|
|
243
|
+
? safeUntrustedLabel(project, WITHHELD_PROJECT_LABEL)
|
|
244
|
+
: "Unattributed";
|
|
245
|
+
// The connected path's operation label is a raw provider/adapter string
|
|
246
|
+
// with NO allowlist in front of it, and it lands in both the title and the
|
|
247
|
+
// instruction. It has to be neutralized here for the same reason the
|
|
248
|
+
// project label is: the report layer's prose sanitizer never blanks, and
|
|
249
|
+
// it is only safe to never blank because core neutralized first.
|
|
250
|
+
const operationLabel = safeUntrustedLabel(operation, WITHHELD_OPERATION_LABEL);
|
|
168
251
|
// Large token volume proves exposure, not that context is removable or
|
|
169
252
|
// what quality/cost delta a change would produce. Context remains an
|
|
170
253
|
// inspect-only action until matched before/after evidence exists.
|
|
@@ -174,11 +257,18 @@ function contextTrimActions(records) {
|
|
|
174
257
|
? `inspect-context-${slug(agent)}-${slug(project ?? "unattributed")}`
|
|
175
258
|
: `inspect-context-${slug(operation)}`,
|
|
176
259
|
title: sessionAggregates
|
|
177
|
-
? `Investigate cumulative context in ${
|
|
178
|
-
: `Inspect oversized context on ${
|
|
260
|
+
? `Investigate cumulative context in ${agentLabel} · ${projectLabel}`
|
|
261
|
+
: `Inspect oversized context on ${operationLabel}`,
|
|
179
262
|
action: sessionAggregates
|
|
180
|
-
?
|
|
181
|
-
|
|
263
|
+
? localContextTrimGuidance({
|
|
264
|
+
count,
|
|
265
|
+
agent: agentLabel,
|
|
266
|
+
projectLabel,
|
|
267
|
+
evidence,
|
|
268
|
+
groupSpendUsd: affectedSpendUsd,
|
|
269
|
+
flaggedSpend
|
|
270
|
+
})
|
|
271
|
+
: `${count} call-level ${operationLabel} record${count === 1 ? "" : "s"} exceeded 100k input tokens. Inspect retrieved chunks and prompt history, then run a matched before/after before claiming savings.`,
|
|
182
272
|
estimatedMonthlySavingsUsd: monthlySavings,
|
|
183
273
|
affectedSpendUsd,
|
|
184
274
|
recordCount: count,
|
|
@@ -186,11 +276,172 @@ function contextTrimActions(records) {
|
|
|
186
276
|
impactBasis: "observed_value_no_counterfactual",
|
|
187
277
|
recordIds: groupRecords.map((record) => record.id),
|
|
188
278
|
confidence: combinedConfidence(groupRecords.map((record) => record.costConfidence)),
|
|
189
|
-
kind: "context_trim"
|
|
279
|
+
kind: "context_trim",
|
|
280
|
+
...(evidence ? { medianDailyInputTokens: evidence.medianDailyInputTokens } : {})
|
|
190
281
|
});
|
|
191
282
|
}
|
|
192
283
|
return actions;
|
|
193
284
|
}
|
|
285
|
+
function dailyContextEvidence(records) {
|
|
286
|
+
if (records.length === 0)
|
|
287
|
+
return null;
|
|
288
|
+
const byDay = new Map();
|
|
289
|
+
for (const record of records) {
|
|
290
|
+
const day = record.timestamp.slice(0, 10);
|
|
291
|
+
const current = byDay.get(day) ?? { input: 0, output: 0 };
|
|
292
|
+
current.input += record.inputTokens;
|
|
293
|
+
current.output += record.outputTokens;
|
|
294
|
+
byDay.set(day, current);
|
|
295
|
+
}
|
|
296
|
+
const days = [...byDay.entries()].sort((left, right) => left[0].localeCompare(right[0]));
|
|
297
|
+
const first = days[0];
|
|
298
|
+
if (!first)
|
|
299
|
+
return null;
|
|
300
|
+
// ONE REAL DAY, not two independent medians. The sentence says "median day
|
|
301
|
+
// carried X input+cache tokens against Y output"; if X and Y came from
|
|
302
|
+
// different calendar days that sentence would describe a day that never
|
|
303
|
+
// happened. So: order the days by input+cache, take the middle OBSERVED day
|
|
304
|
+
// (the lower of the two middles on an even sample, never their average), and
|
|
305
|
+
// read every median figure off that one day.
|
|
306
|
+
const byInput = [...days].sort((left, right) => left[1].input - right[1].input || left[0].localeCompare(right[0]));
|
|
307
|
+
const medianDay = byInput[Math.floor((byInput.length - 1) / 2)];
|
|
308
|
+
const medianDailyInputTokens = medianDay[1].input;
|
|
309
|
+
const medianDailyOutputTokens = medianDay[1].output;
|
|
310
|
+
const peak = days.reduce((best, entry) => (entry[1].input > best[1].input ? entry : best), first);
|
|
311
|
+
const spendByModel = new Map();
|
|
312
|
+
for (const record of records) {
|
|
313
|
+
spendByModel.set(record.model, (spendByModel.get(record.model) ?? 0) + (record.amountUsd ?? 0));
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
activeDays: days.length,
|
|
317
|
+
medianDailyInputTokens,
|
|
318
|
+
medianDailyOutputTokens,
|
|
319
|
+
peakDay: peak[0],
|
|
320
|
+
peakDayInputTokens: peak[1].input,
|
|
321
|
+
peakOverMedian: medianDailyInputTokens > 0 ? peak[1].input / medianDailyInputTokens : 0,
|
|
322
|
+
inputPerOutput: medianDailyOutputTokens > 0
|
|
323
|
+
? medianDailyInputTokens / medianDailyOutputTokens
|
|
324
|
+
: null,
|
|
325
|
+
models: [...spendByModel.entries()]
|
|
326
|
+
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
|
|
327
|
+
.map(([model]) => model)
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* The guidance a local context candidate carries.
|
|
332
|
+
*
|
|
333
|
+
* Two hard constraints shape it:
|
|
334
|
+
*
|
|
335
|
+
* 1. TRUTH. Every clause states something OBSERVED — a median, a date, a
|
|
336
|
+
* ratio, a share of a single-basis total. Nothing predicts a reduction,
|
|
337
|
+
* nothing prices a change, and no clause is emitted when the evidence
|
|
338
|
+
* behind it is absent. Sharper means more specific about what was seen,
|
|
339
|
+
* never more confident about what would happen.
|
|
340
|
+
*
|
|
341
|
+
* 2. GEOMETRY. The grouped terminal/report render quotes this string from its
|
|
342
|
+
* LARGEST member and drops everything before the first ". " (see
|
|
343
|
+
* `groupedCutActionLines` / `rankCutCandidates` in @agent-finops/report).
|
|
344
|
+
* So sentence 1 carries only per-member counts, and sentence 2 onward
|
|
345
|
+
* NAMES the project it describes — otherwise one project's median would be
|
|
346
|
+
* read as the whole group's. For the same reason sentence 1 interpolates
|
|
347
|
+
* no free text: a label containing ". " would truncate the string at the
|
|
348
|
+
* wrong point.
|
|
349
|
+
*/
|
|
350
|
+
function localContextTrimGuidance(input) {
|
|
351
|
+
const { count, evidence } = input;
|
|
352
|
+
// Idempotent: `contextTrimActions` already neutralized the label for the
|
|
353
|
+
// title, and re-running the check on an already-neutral label is a no-op.
|
|
354
|
+
// Repeating it here keeps the guarantee attached to the function that does
|
|
355
|
+
// the interpolating, not to one of its callers.
|
|
356
|
+
const projectLabel = safeUntrustedLabel(input.projectLabel, WITHHELD_PROJECT_LABEL);
|
|
357
|
+
const plural = count === 1 ? "" : "s";
|
|
358
|
+
if (!evidence) {
|
|
359
|
+
// No day-level evidence to quote. Stay honest and short rather than
|
|
360
|
+
// reciting a checklist the product cannot perform.
|
|
361
|
+
return `${count} day + agent + model + project aggregate${plural} each carried at least 100k summed input/cache tokens. ` +
|
|
362
|
+
`Inspect the heaviest sessions in ${projectLabel} before proposing one reversible change.`;
|
|
363
|
+
}
|
|
364
|
+
const dayPlural = evidence.activeDays === 1 ? "" : "s";
|
|
365
|
+
const sentences = [
|
|
366
|
+
`${count} day + agent + model + project aggregate${plural} over ${evidence.activeDays} active day${dayPlural}.`
|
|
367
|
+
];
|
|
368
|
+
const ratio = evidence.inputPerOutput === null ? null : formatRatio(evidence.inputPerOutput);
|
|
369
|
+
sentences.push(evidence.medianDailyOutputTokens > 0
|
|
370
|
+
? `${projectLabel} — median day carried ${formatTokenCount(evidence.medianDailyInputTokens)} input+cache tokens ` +
|
|
371
|
+
`against ${formatTokenCount(evidence.medianDailyOutputTokens)} output${ratio ? ` (${ratio}:1)` : ""}.`
|
|
372
|
+
: `${projectLabel} — median day carried ${formatTokenCount(evidence.medianDailyInputTokens)} input+cache tokens ` +
|
|
373
|
+
"with no output tokens recorded that day.");
|
|
374
|
+
// Only when there is a real lead. A uniform project gets no false one.
|
|
375
|
+
if (evidence.peakOverMedian >= 2) {
|
|
376
|
+
sentences.push(`Heaviest day ${evidence.peakDay} carried ${formatTokenCount(evidence.peakDayInputTokens)}, ` +
|
|
377
|
+
`${formatRatio(evidence.peakOverMedian)}× the median day; dates are each session's last activity.`);
|
|
378
|
+
}
|
|
379
|
+
const share = concentrationClause(input);
|
|
380
|
+
if (share)
|
|
381
|
+
sentences.push(share);
|
|
382
|
+
if (evidence.models.length > 1) {
|
|
383
|
+
// Model ids come off the same untrusted logs the project name does.
|
|
384
|
+
const shown = evidence.models
|
|
385
|
+
.slice(0, 3)
|
|
386
|
+
.map((model) => safeUntrustedLabel(model, WITHHELD_MODEL_LABEL));
|
|
387
|
+
const rest = evidence.models.length - shown.length;
|
|
388
|
+
sentences.push(`${evidence.models.length} models ran there: ${shown.join(", ")}${rest > 0 ? ` and ${rest} more` : ""}.`);
|
|
389
|
+
}
|
|
390
|
+
sentences.push(evidence.peakOverMedian >= 2
|
|
391
|
+
? `Inspect the sessions behind ${evidence.peakDay} before proposing one reversible change.`
|
|
392
|
+
: `Inspect the heaviest sessions in ${projectLabel} before proposing one reversible change.`);
|
|
393
|
+
return sentences.join(" ");
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* "…holds 76% of the flagged claude-code value observed in this window (rank 1
|
|
397
|
+
* of 8 flagged projects)." An accounting fact about ONE denominator — not a
|
|
398
|
+
* savings claim, not a projection.
|
|
399
|
+
*
|
|
400
|
+
* The denominator is this agent's own flagged total, which is also the rank
|
|
401
|
+
* clause's population and the sum of the per-project dollars the same readout
|
|
402
|
+
* prints two lines below. So the percentage, the rank, and the entry's own
|
|
403
|
+
* total reconcile, and the members of one fan-out sum to 100%.
|
|
404
|
+
*
|
|
405
|
+
* 0.9.7 divided by a machine-wide local total instead: `--full` printed a
|
|
406
|
+
* by-project table saying 83%, this sentence said 46%, and the entry's own
|
|
407
|
+
* dollars implied 76% — three true numbers that read as an arithmetic error.
|
|
408
|
+
* No clamp is needed now and none belongs here: the numerator is one member of
|
|
409
|
+
* the set in the denominator, so a ratio above 1 would be a real bug worth
|
|
410
|
+
* seeing rather than a cosmetic one worth hiding.
|
|
411
|
+
*/
|
|
412
|
+
function concentrationClause(input) {
|
|
413
|
+
const { agent, groupSpendUsd, flaggedSpend } = input;
|
|
414
|
+
const flaggedTotalUsd = flaggedSpend.reduce((total, spend) => total + spend, 0);
|
|
415
|
+
if (!(flaggedTotalUsd > 0) || !(groupSpendUsd > 0))
|
|
416
|
+
return null;
|
|
417
|
+
const ratio = groupSpendUsd / flaggedTotalUsd;
|
|
418
|
+
if (!Number.isFinite(ratio) || ratio <= 0)
|
|
419
|
+
return null;
|
|
420
|
+
const percent = Math.round(ratio * 100);
|
|
421
|
+
const rank = flaggedSpend.filter((spend) => spend > groupSpendUsd).length + 1;
|
|
422
|
+
const total = flaggedSpend.length;
|
|
423
|
+
// A ceiling, mirroring the "under 1%" floor. The ordinary solo-dev shape is
|
|
424
|
+
// one main repo and two small side projects: at 99.5%–99.9% the rounded
|
|
425
|
+
// figure prints "100%" on the same screen as an across-line that lists two
|
|
426
|
+
// more flagged projects WITH DOLLARS — which reads as exactly the
|
|
427
|
+
// arithmetic error this sentence's denominator was fixed to kill. "over 99%"
|
|
428
|
+
// is the same fact without the contradiction. A lone flagged project keeps
|
|
429
|
+
// the literal 100%, because there is nothing beside it to contradict.
|
|
430
|
+
const share = percent < 1
|
|
431
|
+
? "under 1%"
|
|
432
|
+
: percent >= 100 && total > 1 ? "over 99%" : `${percent}%`;
|
|
433
|
+
const rankClause = total > 1 ? ` (rank ${rank} of ${total} flagged projects)` : "";
|
|
434
|
+
return `That project holds ${share} of the flagged ${agent} value observed in this window${rankClause}.`;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* "519", "4.4" — integers once the ratio is big enough that a decimal is noise.
|
|
438
|
+
* Shared by the input:output ratio and the "N× the median day" multiple so the
|
|
439
|
+
* two never disagree about precision. Separated above 999, because a bare
|
|
440
|
+
* "1904762:1" is a digit-counting exercise, not a figure.
|
|
441
|
+
*/
|
|
442
|
+
function formatRatio(value) {
|
|
443
|
+
return value >= 10 ? Math.round(value).toLocaleString("en-US") : value.toFixed(1);
|
|
444
|
+
}
|
|
194
445
|
function cacheActions(records) {
|
|
195
446
|
const window = windowDays(records);
|
|
196
447
|
const counts = new Map();
|
|
@@ -226,10 +477,13 @@ function cacheActions(records) {
|
|
|
226
477
|
const affectedSpendUsd = roundMoney(sumRecords(groupRecords));
|
|
227
478
|
const windowSavings = sumRecords(avoidableRecords);
|
|
228
479
|
const monthlySavings = roundMoney(toMonthly(windowSavings, window));
|
|
480
|
+
// No allowlist stands in front of this one at all — any operation label
|
|
481
|
+
// with a stable fingerprint reaches the title and the instruction.
|
|
482
|
+
const operationLabel = safeUntrustedLabel(operation, WITHHELD_OPERATION_LABEL);
|
|
229
483
|
actions.push({
|
|
230
484
|
id: `cache-${slug(operation)}-${stableSuffix(key)}`,
|
|
231
|
-
title: `Cache repeated ${
|
|
232
|
-
action: `Keep the earliest ${
|
|
485
|
+
title: `Cache repeated ${operationLabel} calls`,
|
|
486
|
+
action: `Keep the earliest ${operationLabel} call as the canonical miss and cache the ${avoidableRecords.length} subsequent call${avoidableRecords.length === 1 ? "" : "s"} with the same adapter-provided input fingerprint.`,
|
|
233
487
|
estimatedMonthlySavingsUsd: monthlySavings,
|
|
234
488
|
affectedSpendUsd,
|
|
235
489
|
recordCount: groupRecords.length,
|
|
@@ -274,10 +528,13 @@ function batchActions(records) {
|
|
|
274
528
|
const retainedCost = batchCostRetainedByProvider[groupRecords[0].source.provider];
|
|
275
529
|
const windowSavings = affectedSpendUsd * (1 - retainedCost);
|
|
276
530
|
const monthlySavings = roundMoney(toMonthly(windowSavings, window));
|
|
531
|
+
// `batchSafeOperation` is a SUBSTRING allowlist: "summar" anywhere in the
|
|
532
|
+
// label admits the whole label, injected prose included.
|
|
533
|
+
const operationLabel = safeUntrustedLabel(operation, WITHHELD_OPERATION_LABEL);
|
|
277
534
|
actions.push({
|
|
278
535
|
id: `batch-${slug(operation)}-${stableSuffix(key)}`,
|
|
279
|
-
title: `Move ${
|
|
280
|
-
action: `Submit ${groupRecords.length} ${
|
|
536
|
+
title: `Move ${operationLabel} calls to the Batch API`,
|
|
537
|
+
action: `Submit ${groupRecords.length} ${operationLabel} call${groupRecords.length === 1 ? "" : "s"} through the provider's Batch API (flat 50% off; results within 24h, fine for offline work).`,
|
|
281
538
|
estimatedMonthlySavingsUsd: monthlySavings,
|
|
282
539
|
affectedSpendUsd,
|
|
283
540
|
recordCount: groupRecords.length,
|