@trazum/core 1.8.0
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/LICENSE +21 -0
- package/README.md +163 -0
- package/dist/advisories.d.ts +23 -0
- package/dist/advisories.d.ts.map +1 -0
- package/dist/advisories.js +376 -0
- package/dist/advisories.js.map +1 -0
- package/dist/aws-sigv4.d.ts +88 -0
- package/dist/aws-sigv4.d.ts.map +1 -0
- package/dist/aws-sigv4.js +117 -0
- package/dist/aws-sigv4.js.map +1 -0
- package/dist/baseline.d.ts +171 -0
- package/dist/baseline.d.ts.map +1 -0
- package/dist/baseline.js +273 -0
- package/dist/baseline.js.map +1 -0
- package/dist/cache.d.ts +26 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +28 -0
- package/dist/cache.js.map +1 -0
- package/dist/changes.d.ts +29 -0
- package/dist/changes.d.ts.map +1 -0
- package/dist/changes.js +142 -0
- package/dist/changes.js.map +1 -0
- package/dist/compare.d.ts +65 -0
- package/dist/compare.d.ts.map +1 -0
- package/dist/compare.js +58 -0
- package/dist/compare.js.map +1 -0
- package/dist/config-schema.d.ts +118 -0
- package/dist/config-schema.d.ts.map +1 -0
- package/dist/config-schema.js +315 -0
- package/dist/config-schema.js.map +1 -0
- package/dist/config.d.ts +47 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +112 -0
- package/dist/config.js.map +1 -0
- package/dist/detect.d.ts +70 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/detect.js +228 -0
- package/dist/detect.js.map +1 -0
- package/dist/evaluate.d.ts +98 -0
- package/dist/evaluate.d.ts.map +1 -0
- package/dist/evaluate.js +110 -0
- package/dist/evaluate.js.map +1 -0
- package/dist/extract.d.ts +81 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +280 -0
- package/dist/extract.js.map +1 -0
- package/dist/gcp-auth.d.ts +58 -0
- package/dist/gcp-auth.d.ts.map +1 -0
- package/dist/gcp-auth.js +113 -0
- package/dist/gcp-auth.js.map +1 -0
- package/dist/glob.d.ts +49 -0
- package/dist/glob.d.ts.map +1 -0
- package/dist/glob.js +154 -0
- package/dist/glob.js.map +1 -0
- package/dist/host.d.ts +30 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +69 -0
- package/dist/host.js.map +1 -0
- package/dist/i18n/en.d.ts +4 -0
- package/dist/i18n/en.d.ts.map +1 -0
- package/dist/i18n/en.js +168 -0
- package/dist/i18n/en.js.map +1 -0
- package/dist/i18n/es.d.ts +4 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +168 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/index.d.ts +36 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +50 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/i18n/types.d.ts +180 -0
- package/dist/i18n/types.d.ts.map +1 -0
- package/dist/i18n/types.js +11 -0
- package/dist/i18n/types.js.map +1 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +69 -0
- package/dist/index.js.map +1 -0
- package/dist/llm.d.ts +226 -0
- package/dist/llm.d.ts.map +1 -0
- package/dist/llm.js +485 -0
- package/dist/llm.js.map +1 -0
- package/dist/nearest.d.ts +20 -0
- package/dist/nearest.d.ts.map +1 -0
- package/dist/nearest.js +54 -0
- package/dist/nearest.js.map +1 -0
- package/dist/net.d.ts +90 -0
- package/dist/net.d.ts.map +1 -0
- package/dist/net.js +203 -0
- package/dist/net.js.map +1 -0
- package/dist/node.d.ts +32 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +33 -0
- package/dist/node.js.map +1 -0
- package/dist/openrouter.d.ts +25 -0
- package/dist/openrouter.d.ts.map +1 -0
- package/dist/openrouter.js +72 -0
- package/dist/openrouter.js.map +1 -0
- package/dist/optimize.d.ts +38 -0
- package/dist/optimize.d.ts.map +1 -0
- package/dist/optimize.js +183 -0
- package/dist/optimize.js.map +1 -0
- package/dist/otlp.d.ts +91 -0
- package/dist/otlp.d.ts.map +1 -0
- package/dist/otlp.js +102 -0
- package/dist/otlp.js.map +1 -0
- package/dist/phrases.d.ts +169 -0
- package/dist/phrases.d.ts.map +1 -0
- package/dist/phrases.js +939 -0
- package/dist/phrases.js.map +1 -0
- package/dist/pricing-overlay.d.ts +55 -0
- package/dist/pricing-overlay.d.ts.map +1 -0
- package/dist/pricing-overlay.js +241 -0
- package/dist/pricing-overlay.js.map +1 -0
- package/dist/pricing.d.ts +115 -0
- package/dist/pricing.d.ts.map +1 -0
- package/dist/pricing.js +400 -0
- package/dist/pricing.js.map +1 -0
- package/dist/profile.d.ts +71 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +55 -0
- package/dist/profile.js.map +1 -0
- package/dist/promptfoo.d.ts +58 -0
- package/dist/promptfoo.d.ts.map +1 -0
- package/dist/promptfoo.js +149 -0
- package/dist/promptfoo.js.map +1 -0
- package/dist/prune.d.ts +91 -0
- package/dist/prune.d.ts.map +1 -0
- package/dist/prune.js +110 -0
- package/dist/prune.js.map +1 -0
- package/dist/reorder.d.ts +82 -0
- package/dist/reorder.d.ts.map +1 -0
- package/dist/reorder.js +215 -0
- package/dist/reorder.js.map +1 -0
- package/dist/review.d.ts +54 -0
- package/dist/review.d.ts.map +1 -0
- package/dist/review.js +131 -0
- package/dist/review.js.map +1 -0
- package/dist/rules.d.ts +5 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +279 -0
- package/dist/rules.js.map +1 -0
- package/dist/savings.d.ts +36 -0
- package/dist/savings.d.ts.map +1 -0
- package/dist/savings.js +83 -0
- package/dist/savings.js.map +1 -0
- package/dist/segment.d.ts +8 -0
- package/dist/segment.d.ts.map +1 -0
- package/dist/segment.js +74 -0
- package/dist/segment.js.map +1 -0
- package/dist/shared-prefix.d.ts +63 -0
- package/dist/shared-prefix.d.ts.map +1 -0
- package/dist/shared-prefix.js +151 -0
- package/dist/shared-prefix.js.map +1 -0
- package/dist/similarity.d.ts +13 -0
- package/dist/similarity.d.ts.map +1 -0
- package/dist/similarity.js +30 -0
- package/dist/similarity.js.map +1 -0
- package/dist/structure.d.ts +144 -0
- package/dist/structure.d.ts.map +1 -0
- package/dist/structure.js +455 -0
- package/dist/structure.js.map +1 -0
- package/dist/suggest.d.ts +100 -0
- package/dist/suggest.d.ts.map +1 -0
- package/dist/suggest.js +151 -0
- package/dist/suggest.js.map +1 -0
- package/dist/tokenizer.d.ts +57 -0
- package/dist/tokenizer.d.ts.map +1 -0
- package/dist/tokenizer.js +157 -0
- package/dist/tokenizer.js.map +1 -0
- package/dist/types.d.ts +296 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/walk.d.ts +40 -0
- package/dist/walk.d.ts.map +1 -0
- package/dist/walk.js +95 -0
- package/dist/walk.js.map +1 -0
- package/package.json +56 -0
- package/src/advisories.ts +431 -0
- package/src/aws-sigv4.ts +174 -0
- package/src/baseline.ts +390 -0
- package/src/cache.ts +54 -0
- package/src/changes.ts +158 -0
- package/src/compare.ts +131 -0
- package/src/config-schema.ts +451 -0
- package/src/config.ts +161 -0
- package/src/detect.ts +312 -0
- package/src/evaluate.ts +188 -0
- package/src/extract.ts +336 -0
- package/src/gcp-auth.ts +166 -0
- package/src/glob.ts +160 -0
- package/src/host.ts +90 -0
- package/src/i18n/en.ts +236 -0
- package/src/i18n/es.ts +236 -0
- package/src/i18n/index.ts +68 -0
- package/src/i18n/types.ts +230 -0
- package/src/index.ts +228 -0
- package/src/llm.ts +708 -0
- package/src/nearest.ts +61 -0
- package/src/net.ts +233 -0
- package/src/node.ts +63 -0
- package/src/openrouter.ts +125 -0
- package/src/optimize.ts +228 -0
- package/src/otlp.ts +179 -0
- package/src/phrases.ts +1047 -0
- package/src/pricing-overlay.ts +319 -0
- package/src/pricing.ts +468 -0
- package/src/profile.ts +124 -0
- package/src/promptfoo.ts +213 -0
- package/src/prune.ts +211 -0
- package/src/reorder.ts +307 -0
- package/src/review.ts +180 -0
- package/src/rules.ts +324 -0
- package/src/savings.ts +121 -0
- package/src/segment.ts +106 -0
- package/src/shared-prefix.ts +198 -0
- package/src/similarity.ts +28 -0
- package/src/structure.ts +652 -0
- package/src/suggest.ts +254 -0
- package/src/tokenizer.ts +190 -0
- package/src/types.ts +323 -0
- package/src/walk.ts +117 -0
package/src/baseline.ts
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import type { UsageProfile } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The cost baseline: what this repository's prompts cost as of a commit, and
|
|
5
|
+
* what has happened to them since.
|
|
6
|
+
*
|
|
7
|
+
* **Why this exists.** `budgets` in `trazum.config.json` is a ceiling — it
|
|
8
|
+
* answers "does this file fit". It cannot answer "did this change make things
|
|
9
|
+
* worse", and those are different questions with different failure modes. A
|
|
10
|
+
* repository sitting at 95% of every budget passes every gate forever while a
|
|
11
|
+
* pull request quietly adds four hundred tokens across a dozen files. A ceiling
|
|
12
|
+
* catches the absolute; only a baseline catches the drift.
|
|
13
|
+
*
|
|
14
|
+
* **Why the gate is in tokens and the money is only reported.** A dollar figure
|
|
15
|
+
* is derived from three things: the token count, the usage scenario, and the
|
|
16
|
+
* price list. Two of those change for reasons that have nothing to do with the
|
|
17
|
+
* prompts — a repriced model, an edited `callsPerMonth` — so a baseline holding
|
|
18
|
+
* dollars would fail a build the day the catalogue was updated, calling a price
|
|
19
|
+
* change a regression. A gate that cries wolf is a gate somebody deletes.
|
|
20
|
+
*
|
|
21
|
+
* Tokens depend on the text and nothing else. They are what is compared, they
|
|
22
|
+
* are what the threshold is written in, and the monthly figure is recomputed at
|
|
23
|
+
* comparison time and shown next to it — with an explicit note when the scenario
|
|
24
|
+
* or the price list moved, because a dollar delta across a reprice is two
|
|
25
|
+
* different measurements subtracted from each other.
|
|
26
|
+
*
|
|
27
|
+
* No filesystem access here, deliberately: `apps/web` bundles this package for
|
|
28
|
+
* the browser, and one `node:fs` import anywhere in the graph fails that build.
|
|
29
|
+
* Reading and writing the file is the CLI's job.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The document version.
|
|
34
|
+
*
|
|
35
|
+
* Written into every baseline and checked on read. A baseline is committed and
|
|
36
|
+
* outlives the version of Trazum that wrote it, so a future shape change has to
|
|
37
|
+
* be able to say "this file is from an older format, re-record it" rather than
|
|
38
|
+
* misreading fields that moved.
|
|
39
|
+
*/
|
|
40
|
+
export const BASELINE_VERSION = 1;
|
|
41
|
+
|
|
42
|
+
export const BASELINE_FILENAME = 'trazum.baseline.json';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Largest baseline this will read.
|
|
46
|
+
*
|
|
47
|
+
* Bigger than the config limit because this one scales with the repository: a
|
|
48
|
+
* thousand prompts is a thousand entries. Still bounded, so a corrupt or hostile
|
|
49
|
+
* file is refused before `JSON.parse` is handed the whole thing.
|
|
50
|
+
*/
|
|
51
|
+
export const MAX_BASELINE_BYTES = 4 * 1024 * 1024;
|
|
52
|
+
|
|
53
|
+
export interface BaselineDocument {
|
|
54
|
+
version: number;
|
|
55
|
+
/** ISO date the baseline was recorded, for the report to cite. */
|
|
56
|
+
recorded: string;
|
|
57
|
+
/**
|
|
58
|
+
* The scenario the monthly figure was computed under. Recorded so a later
|
|
59
|
+
* comparison can say whether the money is comparable, not to gate on.
|
|
60
|
+
*/
|
|
61
|
+
scenario: UsageProfile;
|
|
62
|
+
/** `PRICING_LAST_REVIEWED` at the time, for the same reason. */
|
|
63
|
+
pricingReviewed: string;
|
|
64
|
+
totals: { tokens: number; monthlyUsd: number };
|
|
65
|
+
/**
|
|
66
|
+
* Repository-relative path to token count, forward slashes always.
|
|
67
|
+
*
|
|
68
|
+
* Per file rather than one total, because a gate that reports "the repository
|
|
69
|
+
* grew by 400 tokens" without naming the file is a gate people learn to
|
|
70
|
+
* ignore. Sorted on write so re-recording produces a reviewable diff instead
|
|
71
|
+
* of a reordered one.
|
|
72
|
+
*/
|
|
73
|
+
files: Record<string, number>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class BaselineError extends Error {
|
|
77
|
+
constructor(
|
|
78
|
+
message: string,
|
|
79
|
+
readonly source: string,
|
|
80
|
+
) {
|
|
81
|
+
super(`${source}: ${message}`);
|
|
82
|
+
this.name = 'BaselineError';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* An absolute path in any shape a committed file might carry one.
|
|
88
|
+
*
|
|
89
|
+
* Same regex, and the same reasoning, as `config-schema.ts`: written out rather
|
|
90
|
+
* than delegating to `path.isAbsolute` because that is platform-dependent, and a
|
|
91
|
+
* baseline recorded on Windows must be judged identically on a Linux runner or
|
|
92
|
+
* the gate silently matches nothing.
|
|
93
|
+
*/
|
|
94
|
+
const IS_ABSOLUTE = /^(?:[/\\]|[A-Za-z]:[/\\])/;
|
|
95
|
+
|
|
96
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
97
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
98
|
+
|
|
99
|
+
function requireWholeCount(value: unknown, label: string, source: string): number {
|
|
100
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
|
101
|
+
throw new BaselineError(`"${label}" must be a whole number of 0 or more`, source);
|
|
102
|
+
}
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function requireString(value: unknown, label: string, source: string): string {
|
|
107
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
108
|
+
throw new BaselineError(`"${label}" must be a non-empty string`, source);
|
|
109
|
+
}
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function cacheHitRateOf(value: unknown, source: string): number {
|
|
114
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) {
|
|
115
|
+
throw new BaselineError('"scenario.cacheHitRate" is a fraction between 0 and 1', source);
|
|
116
|
+
}
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function batchEligibleOf(value: unknown, source: string): boolean {
|
|
121
|
+
if (typeof value !== 'boolean') {
|
|
122
|
+
throw new BaselineError('"scenario.batchEligible" must be true or false', source);
|
|
123
|
+
}
|
|
124
|
+
return value;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Validates a baseline document.
|
|
129
|
+
*
|
|
130
|
+
* **Every failure throws**, for the reason the config parser gives: this file
|
|
131
|
+
* decides whether a build passes. A lenient read of a malformed baseline is a
|
|
132
|
+
* gate that measured nothing and reported success, which is worse than no gate,
|
|
133
|
+
* because the repository now believes it has one.
|
|
134
|
+
*/
|
|
135
|
+
export function parseBaseline(raw: string, source = BASELINE_FILENAME): BaselineDocument {
|
|
136
|
+
let document: unknown;
|
|
137
|
+
try {
|
|
138
|
+
document = JSON.parse(raw);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
141
|
+
throw new BaselineError(`not valid JSON — ${detail}`, source);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!isPlainObject(document)) {
|
|
145
|
+
throw new BaselineError('the top level must be an object', source);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const version = document.version;
|
|
149
|
+
if (version !== BASELINE_VERSION) {
|
|
150
|
+
throw new BaselineError(
|
|
151
|
+
`is version ${JSON.stringify(version)}, and this Trazum reads version ${BASELINE_VERSION}. ` +
|
|
152
|
+
're-record it with "trazum baseline"',
|
|
153
|
+
source,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!isPlainObject(document.totals)) {
|
|
158
|
+
throw new BaselineError('"totals" must be an object', source);
|
|
159
|
+
}
|
|
160
|
+
if (!isPlainObject(document.files)) {
|
|
161
|
+
throw new BaselineError('"files" must be an object of path to token count', source);
|
|
162
|
+
}
|
|
163
|
+
if (!isPlainObject(document.scenario)) {
|
|
164
|
+
throw new BaselineError('"scenario" must be an object', source);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const files: Record<string, number> = {};
|
|
168
|
+
for (const [path, tokens] of Object.entries(document.files)) {
|
|
169
|
+
if (path.length === 0) throw new BaselineError('"files" has an empty path', source);
|
|
170
|
+
if (IS_ABSOLUTE.test(path) || path.includes('..')) {
|
|
171
|
+
throw new BaselineError(
|
|
172
|
+
`"files" path ${JSON.stringify(path)} must be relative to the project`,
|
|
173
|
+
source,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
files[path] = requireWholeCount(tokens, `files["${path}"]`, source);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const totalUsd = document.totals.monthlyUsd;
|
|
180
|
+
if (typeof totalUsd !== 'number' || !Number.isFinite(totalUsd) || totalUsd < 0) {
|
|
181
|
+
throw new BaselineError('"totals.monthlyUsd" must be a number of 0 or more', source);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const recorded = requireWholeCount(document.totals.tokens, 'totals.tokens', source);
|
|
185
|
+
const summed = Object.values(files).reduce((a, b) => a + b, 0);
|
|
186
|
+
if (recorded !== summed) {
|
|
187
|
+
// A hand-edited total is the one corruption that looks completely normal:
|
|
188
|
+
// the file parses, the gate runs, and it compares against a number nobody
|
|
189
|
+
// measured. Cheap to check, so it is checked.
|
|
190
|
+
throw new BaselineError(
|
|
191
|
+
`"totals.tokens" is ${recorded} but the per-file counts sum to ${summed}`,
|
|
192
|
+
source,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const scenario = document.scenario as Record<string, unknown>;
|
|
197
|
+
return {
|
|
198
|
+
version: BASELINE_VERSION,
|
|
199
|
+
recorded: requireString(document.recorded, 'recorded', source),
|
|
200
|
+
pricingReviewed: requireString(document.pricingReviewed, 'pricingReviewed', source),
|
|
201
|
+
scenario: {
|
|
202
|
+
model: requireString(scenario.model, 'scenario.model', source),
|
|
203
|
+
callsPerMonth: requireWholeCount(scenario.callsPerMonth, 'scenario.callsPerMonth', source),
|
|
204
|
+
avgOutputTokens: requireWholeCount(
|
|
205
|
+
scenario.avgOutputTokens,
|
|
206
|
+
'scenario.avgOutputTokens',
|
|
207
|
+
source,
|
|
208
|
+
),
|
|
209
|
+
cacheHitRate: cacheHitRateOf(scenario.cacheHitRate, source),
|
|
210
|
+
batchEligible: batchEligibleOf(scenario.batchEligible, source),
|
|
211
|
+
},
|
|
212
|
+
totals: { tokens: recorded, monthlyUsd: totalUsd },
|
|
213
|
+
files,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The document as text, ready to commit.
|
|
219
|
+
*
|
|
220
|
+
* Keys are emitted in a fixed order and file paths sorted, so re-recording an
|
|
221
|
+
* unchanged repository produces a byte-identical file. A baseline that reshuffles
|
|
222
|
+
* itself on every write turns every pull request into an unreviewable diff, and
|
|
223
|
+
* the first thing anyone does with an unreviewable diff is stop reading it.
|
|
224
|
+
*/
|
|
225
|
+
export function formatBaseline(document: BaselineDocument): string {
|
|
226
|
+
const files: Record<string, number> = {};
|
|
227
|
+
for (const path of Object.keys(document.files).sort()) files[path] = document.files[path]!;
|
|
228
|
+
|
|
229
|
+
return `${JSON.stringify(
|
|
230
|
+
{
|
|
231
|
+
version: BASELINE_VERSION,
|
|
232
|
+
recorded: document.recorded,
|
|
233
|
+
scenario: {
|
|
234
|
+
model: document.scenario.model,
|
|
235
|
+
callsPerMonth: document.scenario.callsPerMonth,
|
|
236
|
+
avgOutputTokens: document.scenario.avgOutputTokens,
|
|
237
|
+
cacheHitRate: document.scenario.cacheHitRate,
|
|
238
|
+
batchEligible: document.scenario.batchEligible,
|
|
239
|
+
},
|
|
240
|
+
pricingReviewed: document.pricingReviewed,
|
|
241
|
+
totals: document.totals,
|
|
242
|
+
files,
|
|
243
|
+
},
|
|
244
|
+
null,
|
|
245
|
+
2,
|
|
246
|
+
)}\n`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export interface BaselineChange {
|
|
250
|
+
path: string;
|
|
251
|
+
before: number;
|
|
252
|
+
after: number;
|
|
253
|
+
/** `after - before`, so positive is growth — the direction that costs money. */
|
|
254
|
+
delta: number;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface BaselineComparison {
|
|
258
|
+
grown: BaselineChange[];
|
|
259
|
+
shrunk: BaselineChange[];
|
|
260
|
+
/**
|
|
261
|
+
* Present now, absent from the baseline.
|
|
262
|
+
*
|
|
263
|
+
* Counted toward the total, which is the whole reason this field exists: a new
|
|
264
|
+
* prompt is new cost, and a comparison over only the files present in both
|
|
265
|
+
* would let a five-thousand-token addition through every threshold.
|
|
266
|
+
*/
|
|
267
|
+
added: BaselineChange[];
|
|
268
|
+
/** In the baseline, gone from the tree. Never a regression — it is a saving. */
|
|
269
|
+
removed: BaselineChange[];
|
|
270
|
+
tokensBefore: number;
|
|
271
|
+
tokensAfter: number;
|
|
272
|
+
delta: number;
|
|
273
|
+
/** Growth as a percentage of the baseline, or 0 when the baseline was empty. */
|
|
274
|
+
deltaPct: number;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Compares a set of current token counts against a baseline.
|
|
279
|
+
*
|
|
280
|
+
* Pure arithmetic over two maps. It takes counts rather than file contents so
|
|
281
|
+
* the caller decides what a prompt is — directory mode, extracted markers, a
|
|
282
|
+
* hand-picked list — and this stays the one place the comparison is defined.
|
|
283
|
+
*/
|
|
284
|
+
export function compareToBaseline(
|
|
285
|
+
baseline: BaselineDocument,
|
|
286
|
+
current: Record<string, number>,
|
|
287
|
+
): BaselineComparison {
|
|
288
|
+
const grown: BaselineChange[] = [];
|
|
289
|
+
const shrunk: BaselineChange[] = [];
|
|
290
|
+
const added: BaselineChange[] = [];
|
|
291
|
+
const removed: BaselineChange[] = [];
|
|
292
|
+
|
|
293
|
+
for (const path of Object.keys(current).sort()) {
|
|
294
|
+
const after = current[path]!;
|
|
295
|
+
const before = baseline.files[path];
|
|
296
|
+
if (before === undefined) {
|
|
297
|
+
added.push({ path, before: 0, after, delta: after });
|
|
298
|
+
} else if (after > before) {
|
|
299
|
+
grown.push({ path, before, after, delta: after - before });
|
|
300
|
+
} else if (after < before) {
|
|
301
|
+
shrunk.push({ path, before, after, delta: after - before });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
for (const path of Object.keys(baseline.files).sort()) {
|
|
306
|
+
if (current[path] === undefined) {
|
|
307
|
+
const before = baseline.files[path]!;
|
|
308
|
+
removed.push({ path, before, after: 0, delta: -before });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const tokensBefore = baseline.totals.tokens;
|
|
313
|
+
const tokensAfter = Object.values(current).reduce((a, b) => a + b, 0);
|
|
314
|
+
const delta = tokensAfter - tokensBefore;
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
grown,
|
|
318
|
+
shrunk,
|
|
319
|
+
added,
|
|
320
|
+
removed,
|
|
321
|
+
tokensBefore,
|
|
322
|
+
tokensAfter,
|
|
323
|
+
delta,
|
|
324
|
+
deltaPct: tokensBefore === 0 ? 0 : (delta / tokensBefore) * 100,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export interface BaselineThresholds {
|
|
329
|
+
/** Absolute token growth allowed. */
|
|
330
|
+
maxGrowthTokens?: number;
|
|
331
|
+
/** Growth allowed as a percentage of the baseline total. */
|
|
332
|
+
maxGrowthPct?: number;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Why a comparison failed. Structured, so the CLI owns the wording. */
|
|
336
|
+
export type BaselineBreach =
|
|
337
|
+
| { kind: 'tokens'; limit: number; actual: number }
|
|
338
|
+
| { kind: 'pct'; limit: number; actual: number };
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Whether a comparison breaches its thresholds.
|
|
342
|
+
*
|
|
343
|
+
* Both thresholds are checked and **either one failing fails the gate** — they
|
|
344
|
+
* are not alternatives to pick between. A percentage alone lets a small
|
|
345
|
+
* repository absorb a large absolute addition; an absolute number alone means a
|
|
346
|
+
* large repository never trips. Whichever is exceeded is reported, so the output
|
|
347
|
+
* names the limit that was actually crossed rather than a generic failure.
|
|
348
|
+
*
|
|
349
|
+
* Shrinking never fails. There is no such thing as a prompt that got too cheap.
|
|
350
|
+
*/
|
|
351
|
+
export function breaches(
|
|
352
|
+
comparison: BaselineComparison,
|
|
353
|
+
thresholds: BaselineThresholds,
|
|
354
|
+
): BaselineBreach[] {
|
|
355
|
+
const found: BaselineBreach[] = [];
|
|
356
|
+
if (comparison.delta <= 0) return found;
|
|
357
|
+
|
|
358
|
+
if (thresholds.maxGrowthTokens !== undefined && comparison.delta > thresholds.maxGrowthTokens) {
|
|
359
|
+
found.push({ kind: 'tokens', limit: thresholds.maxGrowthTokens, actual: comparison.delta });
|
|
360
|
+
}
|
|
361
|
+
if (thresholds.maxGrowthPct !== undefined && comparison.deltaPct > thresholds.maxGrowthPct) {
|
|
362
|
+
found.push({ kind: 'pct', limit: thresholds.maxGrowthPct, actual: comparison.deltaPct });
|
|
363
|
+
}
|
|
364
|
+
return found;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Whether the baseline's money is comparable to today's.
|
|
369
|
+
*
|
|
370
|
+
* Tokens are always comparable — they depend on the text and nothing else, which
|
|
371
|
+
* is why the gate is written in them. The monthly figure is not: a repriced model
|
|
372
|
+
* or an edited scenario changes it without a single prompt moving. When either
|
|
373
|
+
* has shifted the report says so instead of subtracting two different
|
|
374
|
+
* measurements and presenting the difference as a saving.
|
|
375
|
+
*/
|
|
376
|
+
export function moneyIsComparable(
|
|
377
|
+
baseline: BaselineDocument,
|
|
378
|
+
scenario: UsageProfile,
|
|
379
|
+
pricingReviewed: string,
|
|
380
|
+
): { comparable: boolean; scenarioChanged: boolean; pricingChanged: boolean } {
|
|
381
|
+
const scenarioChanged =
|
|
382
|
+
baseline.scenario.model !== scenario.model ||
|
|
383
|
+
baseline.scenario.callsPerMonth !== scenario.callsPerMonth ||
|
|
384
|
+
baseline.scenario.avgOutputTokens !== scenario.avgOutputTokens ||
|
|
385
|
+
baseline.scenario.cacheHitRate !== scenario.cacheHitRate ||
|
|
386
|
+
baseline.scenario.batchEligible !== scenario.batchEligible;
|
|
387
|
+
const pricingChanged = baseline.pricingReviewed !== pricingReviewed;
|
|
388
|
+
|
|
389
|
+
return { comparable: !scenarioChanged && !pricingChanged, scenarioChanged, pricingChanged };
|
|
390
|
+
}
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { segment } from './segment.js';
|
|
2
|
+
import type { TokenCounter } from './types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Cacheable-prefix analysis.
|
|
6
|
+
*
|
|
7
|
+
* Prompt caching is a byte-for-byte prefix match, so as soon as a template
|
|
8
|
+
* placeholder ({{user}}, ${query}...) is filled with a different value,
|
|
9
|
+
* everything after it stops being cached. The real cacheable prefix of a
|
|
10
|
+
* template is therefore NOT the whole prompt, but whatever precedes the first
|
|
11
|
+
* variable placeholder.
|
|
12
|
+
*/
|
|
13
|
+
export interface CachePrefixAnalysis {
|
|
14
|
+
/** Total tokens in the prompt. */
|
|
15
|
+
totalTokens: number;
|
|
16
|
+
/** Tokens before the first variable placeholder. With no placeholders, the total. */
|
|
17
|
+
stablePrefixTokens: number;
|
|
18
|
+
/** Text of the first variable placeholder, or `null` when there is none. */
|
|
19
|
+
firstPlaceholder: string | null;
|
|
20
|
+
/**
|
|
21
|
+
* Tokens of NON-placeholder content sitting after the first placeholder:
|
|
22
|
+
* stable instructions that are not cached today and that would be cached if
|
|
23
|
+
* moved ahead of the first placeholder.
|
|
24
|
+
*/
|
|
25
|
+
staticTokensAfter: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function analyzeCachePrefix(prompt: string, count: TokenCounter): CachePrefixAnalysis {
|
|
29
|
+
const segments = segment(prompt);
|
|
30
|
+
const totalTokens = count(prompt);
|
|
31
|
+
|
|
32
|
+
let firstPlaceholder: string | null = null;
|
|
33
|
+
let prefix = '';
|
|
34
|
+
let staticAfter = '';
|
|
35
|
+
|
|
36
|
+
for (const seg of segments) {
|
|
37
|
+
if (firstPlaceholder === null) {
|
|
38
|
+
if (seg.kind === 'protected' && seg.protection === 'placeholder') {
|
|
39
|
+
firstPlaceholder = seg.text;
|
|
40
|
+
} else {
|
|
41
|
+
prefix += seg.text;
|
|
42
|
+
}
|
|
43
|
+
} else if (!(seg.kind === 'protected' && seg.protection === 'placeholder')) {
|
|
44
|
+
staticAfter += seg.text;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
totalTokens,
|
|
50
|
+
stablePrefixTokens: firstPlaceholder === null ? totalTokens : count(prefix),
|
|
51
|
+
firstPlaceholder,
|
|
52
|
+
staticTokensAfter: firstPlaceholder === null ? 0 : count(staticAfter),
|
|
53
|
+
};
|
|
54
|
+
}
|
package/src/changes.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-rule change extraction.
|
|
3
|
+
*
|
|
4
|
+
* The report says a rule fired four times and saved nineteen tokens. That is
|
|
5
|
+
* enough to trust the `safe` level and nowhere near enough to review the
|
|
6
|
+
* `aggressive` one, where the honest advice has always been "read the diff" —
|
|
7
|
+
* and the diff was one undifferentiated block for every rule at once.
|
|
8
|
+
*
|
|
9
|
+
* This turns each rule into its own short list of "this became that", so an
|
|
10
|
+
* aggressive run can be judged rule by rule and a single bad rule disabled
|
|
11
|
+
* with `--disable`, instead of the whole level.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface RuleChange {
|
|
15
|
+
/** The text the rule removed or replaced. */
|
|
16
|
+
before: string;
|
|
17
|
+
/** What it became. Empty when the text was simply deleted. */
|
|
18
|
+
after: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Longest run of text either side will be diffed word by word.
|
|
23
|
+
*
|
|
24
|
+
* Everything here runs on attacker-controlled input, so the cost has to be
|
|
25
|
+
* bounded by construction rather than by hoping prompts are small. The common
|
|
26
|
+
* prefix and suffix are trimmed first, which is linear and usually leaves very
|
|
27
|
+
* little, but a rule that edits in a hundred scattered places leaves a middle
|
|
28
|
+
* as long as the prompt. Past this size the extraction is skipped: a list of
|
|
29
|
+
* changes that long is not something anyone reads anyway, and the hit count
|
|
30
|
+
* already says how much happened.
|
|
31
|
+
*/
|
|
32
|
+
const MAX_DIFF_CHARS = 4000;
|
|
33
|
+
|
|
34
|
+
/** Changes reported per rule. The hit count carries the true total. */
|
|
35
|
+
export const DEFAULT_CHANGE_LIMIT = 5;
|
|
36
|
+
|
|
37
|
+
function splitWords(text: string): string[] {
|
|
38
|
+
return text.match(/\s+|[^\s]+/g) ?? [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Length of the common prefix, in characters. */
|
|
42
|
+
function commonPrefix(a: string, b: string): number {
|
|
43
|
+
const max = Math.min(a.length, b.length);
|
|
44
|
+
let i = 0;
|
|
45
|
+
while (i < max && a[i] === b[i]) i++;
|
|
46
|
+
return i;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Length of the common suffix, in characters, not overlapping `start`. */
|
|
50
|
+
function commonSuffix(a: string, b: string, start: number): number {
|
|
51
|
+
const max = Math.min(a.length, b.length) - start;
|
|
52
|
+
let i = 0;
|
|
53
|
+
while (i < max && a[a.length - 1 - i] === b[b.length - 1 - i]) i++;
|
|
54
|
+
return i;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Walks a prefix length back to the start of the word it lands inside. */
|
|
58
|
+
function toWordStart(text: string, length: number): number {
|
|
59
|
+
let i = length;
|
|
60
|
+
while (i > 0 && !/\s/.test(text[i - 1]!)) i--;
|
|
61
|
+
return i;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Shrinks a suffix until it begins at a word boundary.
|
|
66
|
+
*
|
|
67
|
+
* Shrinking rather than growing is the whole point: the common suffix is only
|
|
68
|
+
* common up to the length `commonSuffix` found, so extending it past that
|
|
69
|
+
* would claim two different strings match. Pulling it back instead keeps the
|
|
70
|
+
* guarantee and hands the partial word to the diff, where it belongs.
|
|
71
|
+
*/
|
|
72
|
+
function toWordEnd(before: string, length: number): number {
|
|
73
|
+
let i = length;
|
|
74
|
+
while (i > 0 && !/\s/.test(before[before.length - i]!)) i--;
|
|
75
|
+
return i;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Extracts what a rule changed, as a short list of before/after pairs.
|
|
80
|
+
*
|
|
81
|
+
* Returns an empty list rather than a partial one when the change is too large
|
|
82
|
+
* to summarise usefully — an empty list reads as "nothing to show here", which
|
|
83
|
+
* is honest, where a truncated one would read as "this is all that happened".
|
|
84
|
+
*/
|
|
85
|
+
export function extractChanges(
|
|
86
|
+
before: string,
|
|
87
|
+
after: string,
|
|
88
|
+
limit: number = DEFAULT_CHANGE_LIMIT,
|
|
89
|
+
): RuleChange[] {
|
|
90
|
+
if (before === after) return [];
|
|
91
|
+
|
|
92
|
+
// Rules make local edits, so trimming the shared ends first usually leaves a
|
|
93
|
+
// middle small enough to diff properly — and does it in linear time.
|
|
94
|
+
//
|
|
95
|
+
// The trim is by character but the diff is by word, so both boundaries are
|
|
96
|
+
// pushed back out to whitespace. Without that the middle starts mid-word and
|
|
97
|
+
// the change reads as gibberish: removing "quite" from "be quite accurate"
|
|
98
|
+
// reported itself as `be quit → b`, which is accurate, useless, and worse
|
|
99
|
+
// than showing nothing.
|
|
100
|
+
const start = toWordStart(before, commonPrefix(before, after));
|
|
101
|
+
const end = toWordEnd(before, commonSuffix(before, after, start));
|
|
102
|
+
const beforeMiddle = before.slice(start, before.length - end);
|
|
103
|
+
const afterMiddle = after.slice(start, after.length - end);
|
|
104
|
+
|
|
105
|
+
if (beforeMiddle.length > MAX_DIFF_CHARS || afterMiddle.length > MAX_DIFF_CHARS) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const a = splitWords(beforeMiddle);
|
|
110
|
+
const b = splitWords(afterMiddle);
|
|
111
|
+
|
|
112
|
+
// Longest common subsequence over words, to align the two sides.
|
|
113
|
+
const rows = a.length + 1;
|
|
114
|
+
const cols = b.length + 1;
|
|
115
|
+
const table = new Int32Array(rows * cols);
|
|
116
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
117
|
+
for (let j = b.length - 1; j >= 0; j--) {
|
|
118
|
+
table[i * cols + j] =
|
|
119
|
+
a[i] === b[j]
|
|
120
|
+
? table[(i + 1) * cols + (j + 1)]! + 1
|
|
121
|
+
: Math.max(table[(i + 1) * cols + j]!, table[i * cols + (j + 1)]!);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const changes: RuleChange[] = [];
|
|
126
|
+
let removed = '';
|
|
127
|
+
let added = '';
|
|
128
|
+
|
|
129
|
+
const flush = (): void => {
|
|
130
|
+
if (!removed && !added) return;
|
|
131
|
+
// Whitespace-only churn is not a change anyone needs to review; the
|
|
132
|
+
// whitespace rule's hit count already reports it.
|
|
133
|
+
if (removed.trim() || added.trim()) {
|
|
134
|
+
changes.push({ before: removed.trim(), after: added.trim() });
|
|
135
|
+
}
|
|
136
|
+
removed = '';
|
|
137
|
+
added = '';
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
let i = 0;
|
|
141
|
+
let j = 0;
|
|
142
|
+
while (i < a.length && j < b.length) {
|
|
143
|
+
if (a[i] === b[j]) {
|
|
144
|
+
flush();
|
|
145
|
+
i++;
|
|
146
|
+
j++;
|
|
147
|
+
} else if (table[(i + 1) * cols + j]! >= table[i * cols + (j + 1)]!) {
|
|
148
|
+
removed += a[i++];
|
|
149
|
+
} else {
|
|
150
|
+
added += b[j++];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
while (i < a.length) removed += a[i++];
|
|
154
|
+
while (j < b.length) added += b[j++];
|
|
155
|
+
flush();
|
|
156
|
+
|
|
157
|
+
return changes.slice(0, limit);
|
|
158
|
+
}
|