@trazum/core 1.35.0 → 1.37.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/README.md +23 -0
- package/dist/config-schema.d.ts +18 -2
- package/dist/config-schema.d.ts.map +1 -1
- package/dist/config-schema.js +43 -1
- package/dist/config-schema.js.map +1 -1
- package/dist/fleet.d.ts +107 -0
- package/dist/fleet.d.ts.map +1 -0
- package/dist/fleet.js +138 -0
- package/dist/fleet.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/measured-profile.d.ts +126 -0
- package/dist/measured-profile.d.ts.map +1 -0
- package/dist/measured-profile.js +114 -0
- package/dist/measured-profile.js.map +1 -0
- package/package.json +1 -1
- package/src/config-schema.ts +58 -1
- package/src/fleet.ts +212 -0
- package/src/index.ts +4 -0
- package/src/measured-profile.ts +198 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The usage profile `optimize` multiplies by, taken from a log instead of
|
|
3
|
+
* from somebody's typing.
|
|
4
|
+
*
|
|
5
|
+
* `optimize` prices a prompt change as `token delta × callsPerMonth`, with
|
|
6
|
+
* `avgOutputTokens` and `cacheHitRate` shaping the rest. Those three numbers
|
|
7
|
+
* are typed into a config file by a human who is guessing, and the two values
|
|
8
|
+
* most often typed are `1000` and whatever the README's example used. A usage
|
|
9
|
+
* log sitting in the same repository knows all three exactly.
|
|
10
|
+
*
|
|
11
|
+
* **What this module does not do is turn an estimate into a fact.** The token
|
|
12
|
+
* delta stays an estimate with its ±10% band; what stops being a guess is
|
|
13
|
+
* everything it is multiplied by. That distinction has to survive into the
|
|
14
|
+
* rendering, which is why `MeasuredUsage` carries the provenance of each
|
|
15
|
+
* figure rather than only the figures.
|
|
16
|
+
*
|
|
17
|
+
* The hardest decision here is the call count. A month's saving from a log
|
|
18
|
+
* covering three days is a forecast wearing arithmetic's clothes, and this
|
|
19
|
+
* repository refuses those. So the scaling has a floor, it is stated whenever
|
|
20
|
+
* it happens, and under the floor the tool reports the period it measured and
|
|
21
|
+
* declines the multiplication rather than performing it quietly.
|
|
22
|
+
*/
|
|
23
|
+
import type { UsageProfileReport } from './usage.js';
|
|
24
|
+
import type { UsageProfile } from './types.js';
|
|
25
|
+
/**
|
|
26
|
+
* The shortest span that may be scaled to a month, in days.
|
|
27
|
+
*
|
|
28
|
+
* A full week, because the week is the cycle traffic actually has: weekdays
|
|
29
|
+
* against weekends is the one periodicity nearly every workload shows, and a
|
|
30
|
+
* span shorter than one cycle scaled up multiplies whichever part of the
|
|
31
|
+
* cycle it happened to catch. Three weekdays scaled to a month is not a
|
|
32
|
+
* monthly figure, it is a Tuesday with a multiplier.
|
|
33
|
+
*
|
|
34
|
+
* Above the floor this is still a *rate*, not a prediction: "at the rate this
|
|
35
|
+
* log measured" is arithmetic about the past, and every rendering says so.
|
|
36
|
+
*/
|
|
37
|
+
export declare const MIN_SCALE_DAYS = 7;
|
|
38
|
+
/** Days in the month this scales to. Stated rather than hidden in a constant. */
|
|
39
|
+
export declare const SCALE_TO_DAYS = 30;
|
|
40
|
+
export interface MeasuredUsage {
|
|
41
|
+
/** Ready to hand to `optimize`. Every field below is measured, not typed. */
|
|
42
|
+
profile: UsageProfile;
|
|
43
|
+
/** Calls the log actually recorded for this slice. Never scaled. */
|
|
44
|
+
calls: number;
|
|
45
|
+
/** What those calls actually cost. The measured half of any comparison. */
|
|
46
|
+
spentUsd: number;
|
|
47
|
+
/**
|
|
48
|
+
* The period the calls fall in, in days, or null when no call carried a
|
|
49
|
+
* clock. Null is why `scaled` can be null with calls above the floor.
|
|
50
|
+
*/
|
|
51
|
+
spanDays: number | null;
|
|
52
|
+
/**
|
|
53
|
+
* How `callsPerMonth` was reached, or **null when it was not scaled at
|
|
54
|
+
* all** — in which case `profile.callsPerMonth` is the raw measured count
|
|
55
|
+
* over whatever period the log covers, and the rendering must say so
|
|
56
|
+
* rather than printing it under a "per month" heading.
|
|
57
|
+
*/
|
|
58
|
+
scaled: {
|
|
59
|
+
fromDays: number;
|
|
60
|
+
factor: number;
|
|
61
|
+
} | null;
|
|
62
|
+
/**
|
|
63
|
+
* The share of input tokens served from cache, 0–1.
|
|
64
|
+
*
|
|
65
|
+
* `UsageProfile.cacheHitRate` is documented as the fraction of *calls* that
|
|
66
|
+
* reuse the prefix; what a log can measure is the fraction of input
|
|
67
|
+
* *tokens* that were cache reads. Those are the same number only when every
|
|
68
|
+
* call is the same size. It is handed over as the better of two
|
|
69
|
+
* approximations — a measured token share beats a typed call share — and
|
|
70
|
+
* named honestly here so no rendering can call it something it is not.
|
|
71
|
+
*/
|
|
72
|
+
cacheReadShare: number;
|
|
73
|
+
/**
|
|
74
|
+
* The model carrying the most spend in this slice, and how many models the
|
|
75
|
+
* slice used at all. Above one, the single model handed to `optimize` is a
|
|
76
|
+
* simplification and the rendering says which share it covers.
|
|
77
|
+
*/
|
|
78
|
+
models: {
|
|
79
|
+
chosen: string;
|
|
80
|
+
count: number;
|
|
81
|
+
chosenShareOfSpend: number;
|
|
82
|
+
};
|
|
83
|
+
/** True when no call in the slice recorded any output tokens. */
|
|
84
|
+
outputUnmeasured: boolean;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Derives the usage profile for one label from a measured report.
|
|
88
|
+
*
|
|
89
|
+
* Returns `null` when the label carries no priced calls — a slice with no
|
|
90
|
+
* measured traffic has nothing to hand over, and inventing a zero-call
|
|
91
|
+
* profile would produce a $0 saving that reads as "this change is worthless"
|
|
92
|
+
* rather than "nothing here was measured".
|
|
93
|
+
*/
|
|
94
|
+
export declare function measuredUsage(report: UsageProfileReport, label: string, options?: {
|
|
95
|
+
batchEligible?: boolean;
|
|
96
|
+
}): MeasuredUsage | null;
|
|
97
|
+
/**
|
|
98
|
+
* Every label the report priced, with the prompt file the config maps to it —
|
|
99
|
+
* and, deliberately, both kinds of mismatch.
|
|
100
|
+
*
|
|
101
|
+
* The two failures this surfaces are the ones a person cannot see from either
|
|
102
|
+
* side alone: a prompt file mapped to a label that no longer appears in the
|
|
103
|
+
* log (renamed, retired, or a typo that has been silently doing nothing), and
|
|
104
|
+
* a label carrying real money with no prompt file mapped at all (the workload
|
|
105
|
+
* nobody can optimise because nobody said where it lives).
|
|
106
|
+
*/
|
|
107
|
+
export interface LabelCoverage {
|
|
108
|
+
/** Labels with both traffic and a mapped prompt file. */
|
|
109
|
+
joined: {
|
|
110
|
+
label: string;
|
|
111
|
+
promptPath: string;
|
|
112
|
+
spentUsd: number;
|
|
113
|
+
}[];
|
|
114
|
+
/** Mapped prompt files whose label has no priced traffic in this log. */
|
|
115
|
+
mappedWithoutTraffic: {
|
|
116
|
+
label: string;
|
|
117
|
+
promptPath: string;
|
|
118
|
+
}[];
|
|
119
|
+
/** Labels with priced traffic and no prompt file mapped, dearest first. */
|
|
120
|
+
trafficWithoutPrompt: {
|
|
121
|
+
label: string;
|
|
122
|
+
spentUsd: number;
|
|
123
|
+
}[];
|
|
124
|
+
}
|
|
125
|
+
export declare function labelCoverage(report: UsageProfileReport, labels: Record<string, string>): LabelCoverage;
|
|
126
|
+
//# sourceMappingURL=measured-profile.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"measured-profile.d.ts","sourceRoot":"","sources":["../src/measured-profile.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAGH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE/C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,cAAc,IAAI,CAAC;AAEhC,iFAAiF;AACjF,eAAO,MAAM,aAAa,KAAK,CAAC;AAEhC,MAAM,WAAW,aAAa;IAC5B,6EAA6E;IAC7E,OAAO,EAAE,YAAY,CAAC;IACtB,oEAAoE;IACpE,KAAK,EAAE,MAAM,CAAC;IACd,2EAA2E;IAC3E,QAAQ,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB;;;;;OAKG;IACH,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACpD;;;;;;;;;OASG;IACH,cAAc,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,MAAM,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,kBAAkB,EAAE,MAAM,CAAA;KAAE,CAAC;IACtE,iEAAiE;IACjE,gBAAgB,EAAE,OAAO,CAAC;CAC3B;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,kBAAkB,EAC1B,KAAK,EAAE,MAAM,EACb,OAAO,GAAE;IAAE,aAAa,CAAC,EAAE,OAAO,CAAA;CAAO,GACxC,aAAa,GAAG,IAAI,CAuDtB;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,aAAa;IAC5B,yDAAyD;IACzD,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAClE,yEAAyE;IACzE,oBAAoB,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC9D,2EAA2E;IAC3E,oBAAoB,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC7D;AAED,wBAAgB,aAAa,CAC3B,MAAM,EAAE,kBAAkB,EAC1B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC7B,aAAa,CAsBf"}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The usage profile `optimize` multiplies by, taken from a log instead of
|
|
3
|
+
* from somebody's typing.
|
|
4
|
+
*
|
|
5
|
+
* `optimize` prices a prompt change as `token delta × callsPerMonth`, with
|
|
6
|
+
* `avgOutputTokens` and `cacheHitRate` shaping the rest. Those three numbers
|
|
7
|
+
* are typed into a config file by a human who is guessing, and the two values
|
|
8
|
+
* most often typed are `1000` and whatever the README's example used. A usage
|
|
9
|
+
* log sitting in the same repository knows all three exactly.
|
|
10
|
+
*
|
|
11
|
+
* **What this module does not do is turn an estimate into a fact.** The token
|
|
12
|
+
* delta stays an estimate with its ±10% band; what stops being a guess is
|
|
13
|
+
* everything it is multiplied by. That distinction has to survive into the
|
|
14
|
+
* rendering, which is why `MeasuredUsage` carries the provenance of each
|
|
15
|
+
* figure rather than only the figures.
|
|
16
|
+
*
|
|
17
|
+
* The hardest decision here is the call count. A month's saving from a log
|
|
18
|
+
* covering three days is a forecast wearing arithmetic's clothes, and this
|
|
19
|
+
* repository refuses those. So the scaling has a floor, it is stated whenever
|
|
20
|
+
* it happens, and under the floor the tool reports the period it measured and
|
|
21
|
+
* declines the multiplication rather than performing it quietly.
|
|
22
|
+
*/
|
|
23
|
+
import { UNLABELLED } from './usage.js';
|
|
24
|
+
/**
|
|
25
|
+
* The shortest span that may be scaled to a month, in days.
|
|
26
|
+
*
|
|
27
|
+
* A full week, because the week is the cycle traffic actually has: weekdays
|
|
28
|
+
* against weekends is the one periodicity nearly every workload shows, and a
|
|
29
|
+
* span shorter than one cycle scaled up multiplies whichever part of the
|
|
30
|
+
* cycle it happened to catch. Three weekdays scaled to a month is not a
|
|
31
|
+
* monthly figure, it is a Tuesday with a multiplier.
|
|
32
|
+
*
|
|
33
|
+
* Above the floor this is still a *rate*, not a prediction: "at the rate this
|
|
34
|
+
* log measured" is arithmetic about the past, and every rendering says so.
|
|
35
|
+
*/
|
|
36
|
+
export const MIN_SCALE_DAYS = 7;
|
|
37
|
+
/** Days in the month this scales to. Stated rather than hidden in a constant. */
|
|
38
|
+
export const SCALE_TO_DAYS = 30;
|
|
39
|
+
/**
|
|
40
|
+
* Derives the usage profile for one label from a measured report.
|
|
41
|
+
*
|
|
42
|
+
* Returns `null` when the label carries no priced calls — a slice with no
|
|
43
|
+
* measured traffic has nothing to hand over, and inventing a zero-call
|
|
44
|
+
* profile would produce a $0 saving that reads as "this change is worthless"
|
|
45
|
+
* rather than "nothing here was measured".
|
|
46
|
+
*/
|
|
47
|
+
export function measuredUsage(report, label, options = {}) {
|
|
48
|
+
const slices = report.byLabelAndModel.filter((row) => row.label === label);
|
|
49
|
+
if (slices.length === 0)
|
|
50
|
+
return null;
|
|
51
|
+
const calls = slices.reduce((sum, row) => sum + row.breakdown.calls, 0);
|
|
52
|
+
if (calls === 0)
|
|
53
|
+
return null;
|
|
54
|
+
const spentUsd = slices.reduce((sum, row) => sum + row.breakdown.totalUsd, 0);
|
|
55
|
+
const outputTokens = slices.reduce((sum, row) => sum + row.breakdown.outputTokens, 0);
|
|
56
|
+
const inputTokens = slices.reduce((sum, row) => sum + row.breakdown.inputTokens, 0);
|
|
57
|
+
const cacheReadTokens = slices.reduce((sum, row) => sum + row.breakdown.cacheReadTokens, 0);
|
|
58
|
+
// The model carrying the most spend. Ties break on the larger call count, so
|
|
59
|
+
// the answer is stable rather than dependent on map ordering.
|
|
60
|
+
const ranked = [...slices].sort((a, b) => b.breakdown.totalUsd - a.breakdown.totalUsd || b.breakdown.calls - a.breakdown.calls);
|
|
61
|
+
const chosen = ranked[0];
|
|
62
|
+
const chosenShareOfSpend = spentUsd > 0 ? chosen.breakdown.totalUsd / spentUsd : 0;
|
|
63
|
+
/**
|
|
64
|
+
* The span of *this slice*, not of the whole log. A label active for three
|
|
65
|
+
* days of a thirty-day log has a three-day rate, and using the log's span
|
|
66
|
+
* would divide its calls across weeks it never ran in.
|
|
67
|
+
*/
|
|
68
|
+
const spanDays = report.span === null ? null : (report.span.toMs - report.span.fromMs) / 86_400_000;
|
|
69
|
+
const scaled = spanDays !== null && spanDays >= MIN_SCALE_DAYS
|
|
70
|
+
? { fromDays: spanDays, factor: SCALE_TO_DAYS / spanDays }
|
|
71
|
+
: null;
|
|
72
|
+
const readShare = inputTokens + cacheReadTokens > 0 ? cacheReadTokens / (inputTokens + cacheReadTokens) : 0;
|
|
73
|
+
return {
|
|
74
|
+
profile: {
|
|
75
|
+
model: chosen.model,
|
|
76
|
+
// Scaled when the span earns it, the raw measured count otherwise. The
|
|
77
|
+
// caller must read `scaled` before printing this under any heading that
|
|
78
|
+
// says "month".
|
|
79
|
+
callsPerMonth: scaled === null ? calls : Math.round(calls * scaled.factor),
|
|
80
|
+
avgOutputTokens: Math.round(outputTokens / calls),
|
|
81
|
+
cacheHitRate: readShare,
|
|
82
|
+
batchEligible: options.batchEligible ?? false,
|
|
83
|
+
},
|
|
84
|
+
calls,
|
|
85
|
+
spentUsd,
|
|
86
|
+
spanDays,
|
|
87
|
+
scaled,
|
|
88
|
+
cacheReadShare: readShare,
|
|
89
|
+
models: { chosen: chosen.model, count: slices.length, chosenShareOfSpend },
|
|
90
|
+
outputUnmeasured: outputTokens === 0,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
export function labelCoverage(report, labels) {
|
|
94
|
+
const spendByLabel = new Map(report.byLabel.map((row) => [row.label, row.breakdown.totalUsd]));
|
|
95
|
+
const joined = [];
|
|
96
|
+
const mappedWithoutTraffic = [];
|
|
97
|
+
for (const [label, promptPath] of Object.entries(labels)) {
|
|
98
|
+
const spentUsd = spendByLabel.get(label);
|
|
99
|
+
if (spentUsd === undefined)
|
|
100
|
+
mappedWithoutTraffic.push({ label, promptPath });
|
|
101
|
+
else
|
|
102
|
+
joined.push({ label, promptPath, spentUsd });
|
|
103
|
+
}
|
|
104
|
+
const trafficWithoutPrompt = report.byLabel
|
|
105
|
+
// The unlabelled bucket is not a workload somebody forgot to map; it is
|
|
106
|
+
// calls that carry no label at all, which `fieldCoverage` already reports.
|
|
107
|
+
.filter((row) => row.label !== UNLABELLED && labels[row.label] === undefined)
|
|
108
|
+
.map((row) => ({ label: row.label, spentUsd: row.breakdown.totalUsd }))
|
|
109
|
+
.sort((a, b) => b.spentUsd - a.spentUsd);
|
|
110
|
+
joined.sort((a, b) => b.spentUsd - a.spentUsd);
|
|
111
|
+
mappedWithoutTraffic.sort((a, b) => a.label.localeCompare(b.label));
|
|
112
|
+
return { joined, mappedWithoutTraffic, trafficWithoutPrompt };
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=measured-profile.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"measured-profile.js","sourceRoot":"","sources":["../src/measured-profile.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trazum/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.37.0",
|
|
4
4
|
"description": "Trazum core: priced advisories for LLM prompts (caching, model tier, batching, schemas), plus deterministic trimming, token counting and pricing.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "David Mu\u00f1oz Rey",
|
package/src/config-schema.ts
CHANGED
|
@@ -82,6 +82,14 @@ export interface SpendConfig {
|
|
|
82
82
|
maxSessionUsd?: number;
|
|
83
83
|
/** Per-label budgets, each gated against that label's own spend. */
|
|
84
84
|
byLabel?: Record<string, number>;
|
|
85
|
+
/**
|
|
86
|
+
* Money budgets per source, in dollars — the fleet's version of `byLabel`.
|
|
87
|
+
* A source is a named service from the top-level `sources` block, and a
|
|
88
|
+
* budget written here fails `profile --by-source` when that service alone
|
|
89
|
+
* crosses it, with the failing service named rather than a total that
|
|
90
|
+
* hides which.
|
|
91
|
+
*/
|
|
92
|
+
bySource?: Record<string, number>;
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
export interface TrazumConfig {
|
|
@@ -120,6 +128,14 @@ export interface TrazumConfig {
|
|
|
120
128
|
* workload against its own limit in the same run. A flag still wins over
|
|
121
129
|
* the config, as everywhere in this tool.
|
|
122
130
|
*/
|
|
131
|
+
/**
|
|
132
|
+
* The fleet: named services, each a list of glob patterns over usage-log
|
|
133
|
+
* paths. `profile --by-source` builds one report per source plus a rollup,
|
|
134
|
+
* assigning each file to the most specific matching pattern — the same
|
|
135
|
+
* precedence rule the budget patterns use. A file matching no source is
|
|
136
|
+
* named in the output rather than silently joining no report.
|
|
137
|
+
*/
|
|
138
|
+
sources?: Record<string, string[]>;
|
|
123
139
|
spend?: SpendConfig;
|
|
124
140
|
/**
|
|
125
141
|
* Findings as policy: a gate failure the team has looked at and decided to
|
|
@@ -169,6 +185,7 @@ export const CONFIG_KEYS = [
|
|
|
169
185
|
'budgets',
|
|
170
186
|
'labels',
|
|
171
187
|
'spend',
|
|
188
|
+
'sources',
|
|
172
189
|
'waive',
|
|
173
190
|
'maxGrowth',
|
|
174
191
|
'baseline',
|
|
@@ -178,7 +195,7 @@ export const CONFIG_KEYS = [
|
|
|
178
195
|
|
|
179
196
|
export const CONFIG_BASELINE_KEYS = ['path', 'maxGrowthTokens', 'maxGrowthPct'] as const;
|
|
180
197
|
|
|
181
|
-
export const CONFIG_SPEND_KEYS = ['maxUsd', 'maxDayUsd', 'maxSessionUsd', 'byLabel'] as const;
|
|
198
|
+
export const CONFIG_SPEND_KEYS = ['maxUsd', 'maxDayUsd', 'maxSessionUsd', 'byLabel', 'bySource'] as const;
|
|
182
199
|
|
|
183
200
|
export const CONFIG_WAIVE_KEYS = ['gate', 'reason', 'until'] as const;
|
|
184
201
|
|
|
@@ -385,6 +402,19 @@ function parseSpend(raw: unknown, source: string): SpendConfig {
|
|
|
385
402
|
if (raw.maxSessionUsd !== undefined) {
|
|
386
403
|
spend.maxSessionUsd = requireNonNegativeNumber(raw.maxSessionUsd, 'spend.maxSessionUsd', source);
|
|
387
404
|
}
|
|
405
|
+
if (raw.bySource !== undefined) {
|
|
406
|
+
if (!isPlainObject(raw.bySource)) {
|
|
407
|
+
throw new ConfigError('"spend.bySource" must be an object', source);
|
|
408
|
+
}
|
|
409
|
+
const bySource: Record<string, number> = {};
|
|
410
|
+
for (const [name, value] of Object.entries(raw.bySource)) {
|
|
411
|
+
if (name.trim().length === 0) {
|
|
412
|
+
throw new ConfigError('"spend.bySource" has an empty source name', source);
|
|
413
|
+
}
|
|
414
|
+
bySource[name] = requireNonNegativeNumber(value, `spend.bySource["${name}"]`, source);
|
|
415
|
+
}
|
|
416
|
+
spend.bySource = bySource;
|
|
417
|
+
}
|
|
388
418
|
if (raw.byLabel !== undefined) {
|
|
389
419
|
if (!isPlainObject(raw.byLabel)) {
|
|
390
420
|
throw new ConfigError('"spend.byLabel" must be an object', source);
|
|
@@ -401,6 +431,32 @@ function parseSpend(raw: unknown, source: string): SpendConfig {
|
|
|
401
431
|
return spend;
|
|
402
432
|
}
|
|
403
433
|
|
|
434
|
+
/**
|
|
435
|
+
* Validates the `sources` block: named services, each a non-empty list of
|
|
436
|
+
* glob patterns. Patterns are not checked against a filesystem here — a
|
|
437
|
+
* config file is validated wherever it is read, browser included, and which
|
|
438
|
+
* files exist is the CLI's question at run time.
|
|
439
|
+
*/
|
|
440
|
+
function parseSources(raw: unknown, source: string): Record<string, string[]> {
|
|
441
|
+
if (!isPlainObject(raw)) throw new ConfigError('"sources" must be an object', source);
|
|
442
|
+
const sources: Record<string, string[]> = {};
|
|
443
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
444
|
+
if (name.trim().length === 0) {
|
|
445
|
+
throw new ConfigError('"sources" has an empty source name', source);
|
|
446
|
+
}
|
|
447
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
448
|
+
throw new ConfigError(`"sources.${name}" must be a non-empty array of glob patterns`, source);
|
|
449
|
+
}
|
|
450
|
+
for (const pattern of value) {
|
|
451
|
+
if (typeof pattern !== 'string' || pattern.trim().length === 0) {
|
|
452
|
+
throw new ConfigError(`"sources.${name}" has an empty pattern`, source);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
sources[name] = value as string[];
|
|
456
|
+
}
|
|
457
|
+
return sources;
|
|
458
|
+
}
|
|
459
|
+
|
|
404
460
|
/**
|
|
405
461
|
* Validates the `waive` list.
|
|
406
462
|
*
|
|
@@ -597,6 +653,7 @@ export function parseConfig(raw: string, source = CONFIG_FILENAME): TrazumConfig
|
|
|
597
653
|
if (document.budgets !== undefined) config.budgets = parseBudgets(document.budgets, source);
|
|
598
654
|
if (document.labels !== undefined) config.labels = parseLabels(document.labels, source);
|
|
599
655
|
if (document.spend !== undefined) config.spend = parseSpend(document.spend, source);
|
|
656
|
+
if (document.sources !== undefined) config.sources = parseSources(document.sources, source);
|
|
600
657
|
if (document.waive !== undefined) config.waive = parseWaive(document.waive, source);
|
|
601
658
|
if (document.baseline !== undefined) {
|
|
602
659
|
config.baseline = parseBaselineConfig(document.baseline, source);
|
package/src/fleet.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twelve services, one rollup, and the one that is actually bleeding.
|
|
3
|
+
*
|
|
4
|
+
* `profile` merges a directory of logs into one bill, which is right for one
|
|
5
|
+
* service and wrong for a fleet: the merged report hides which service the
|
|
6
|
+
* money is coming from, per-service budgets cannot exist at all, and the
|
|
7
|
+
* findings a *comparison between services* could make are invisible — the
|
|
8
|
+
* same workload on Opus in one team and Haiku in another is a decision
|
|
9
|
+
* somebody should get to see, and no single merged total shows it.
|
|
10
|
+
*
|
|
11
|
+
* This module does the fleet arithmetic on reports the caller already built.
|
|
12
|
+
* It reads no files and runs no globs against a filesystem — the caller hands
|
|
13
|
+
* it file names and per-source reports, so the module stays browser-safe and
|
|
14
|
+
* the CLI keeps its monopoly on I/O.
|
|
15
|
+
*
|
|
16
|
+
* **The rollup refuses to average what it cannot compare.** Two sources whose
|
|
17
|
+
* logs cover different periods can be *summed* — a total is a total — but a
|
|
18
|
+
* share of that total is not a comparison of rates, and the module says which
|
|
19
|
+
* sources cover which days rather than letting a 3-day log look cheap beside
|
|
20
|
+
* a 30-day one.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { mostSpecificMatch } from './glob.js';
|
|
24
|
+
import { UNLABELLED } from './usage.js';
|
|
25
|
+
import type { UsageProfileReport } from './usage.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Which source each file belongs to.
|
|
29
|
+
*
|
|
30
|
+
* Assignment is by the most specific matching glob, so `services/api/**`
|
|
31
|
+
* beats `services/**` on the same file — the same tie-break the budget
|
|
32
|
+
* patterns use, because two rules for pattern precedence in one tool is one
|
|
33
|
+
* rule too many. Files matching no source are returned rather than dropped:
|
|
34
|
+
* a log that silently joined no report would be spend missing from every
|
|
35
|
+
* bill, which is the flattering omission this repository refuses everywhere.
|
|
36
|
+
*/
|
|
37
|
+
export function assignSources(
|
|
38
|
+
files: string[],
|
|
39
|
+
sources: Record<string, string[]>,
|
|
40
|
+
): { bySource: Map<string, string[]>; unmatched: string[] } {
|
|
41
|
+
const bySource = new Map<string, string[]>();
|
|
42
|
+
const unmatched: string[] = [];
|
|
43
|
+
|
|
44
|
+
// Flatten to (pattern, source) pairs so specificity decides across sources.
|
|
45
|
+
const patterns: { pattern: string; source: string }[] = [];
|
|
46
|
+
for (const [source, globs] of Object.entries(sources)) {
|
|
47
|
+
for (const pattern of globs) patterns.push({ pattern, source });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const file of files) {
|
|
51
|
+
const best = mostSpecificMatch(
|
|
52
|
+
patterns.map((p) => p.pattern),
|
|
53
|
+
file,
|
|
54
|
+
);
|
|
55
|
+
if (best === null) {
|
|
56
|
+
unmatched.push(file);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const source = patterns.find((p) => p.pattern === best)!.source;
|
|
60
|
+
const list = bySource.get(source) ?? [];
|
|
61
|
+
list.push(file);
|
|
62
|
+
bySource.set(source, list);
|
|
63
|
+
}
|
|
64
|
+
return { bySource, unmatched };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface FleetSource {
|
|
68
|
+
name: string;
|
|
69
|
+
report: UsageProfileReport;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface FleetRollup {
|
|
73
|
+
/** Sum over every source. A total is a total, whatever the spans. */
|
|
74
|
+
totalUsd: number;
|
|
75
|
+
calls: number;
|
|
76
|
+
/** Every source, dearest first, with its share of the fleet's total. */
|
|
77
|
+
sources: { name: string; usd: number; calls: number; share: number; spanDays: number | null }[];
|
|
78
|
+
/**
|
|
79
|
+
* The one that is actually bleeding: the dearest source, with its share —
|
|
80
|
+
* or null when the fleet spent nothing, because "nothing is bleeding" and
|
|
81
|
+
* "the worst of nothing" are different statements.
|
|
82
|
+
*/
|
|
83
|
+
worst: { name: string; usd: number; share: number } | null;
|
|
84
|
+
/**
|
|
85
|
+
* True when the sources' logs cover meaningfully different periods (their
|
|
86
|
+
* spans differ by more than one day, or some carry no clock at all). Shares
|
|
87
|
+
* of the total remain valid — they are shares of a sum — but reading them
|
|
88
|
+
* as *rate* comparisons is exactly the mistake this flag exists to stop,
|
|
89
|
+
* and every rendering states it when set.
|
|
90
|
+
*/
|
|
91
|
+
mismatchedSpans: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* The same workload label running on different models in different sources
|
|
94
|
+
* — one team on Opus, another on Haiku, same job. A merged bill renders
|
|
95
|
+
* this invisible: the label's slices coexist with no seam. Only splits
|
|
96
|
+
* where both sides carry real spend are reported, dearest gap first.
|
|
97
|
+
*/
|
|
98
|
+
splitBrains: {
|
|
99
|
+
label: string;
|
|
100
|
+
sources: { name: string; model: string; usd: number }[];
|
|
101
|
+
}[];
|
|
102
|
+
/**
|
|
103
|
+
* Sources where caching lost money while the fleet's aggregate paid off.
|
|
104
|
+
* An aggregate verdict is the flattering rendering when three sources are
|
|
105
|
+
* quietly underwater; each is named with its own delta. Sources whose
|
|
106
|
+
* verdict matches the aggregate are not listed — this is the exception
|
|
107
|
+
* report, not the census.
|
|
108
|
+
*/
|
|
109
|
+
cacheUnderwater: { name: string; deltaUsd: number }[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Span length in days, or null when the report has no clock. */
|
|
113
|
+
function spanDaysOf(report: UsageProfileReport): number | null {
|
|
114
|
+
return report.span === null ? null : (report.span.toMs - report.span.fromMs) / 86_400_000;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function fleetRollup(
|
|
118
|
+
sources: FleetSource[],
|
|
119
|
+
options: {
|
|
120
|
+
/**
|
|
121
|
+
* Per-source cache verdict delta, positive meaning caching added money to
|
|
122
|
+
* the bill — the caller computes it with `cacheEconomics` because that
|
|
123
|
+
* module owns the counterfactual, and this one must not restate it.
|
|
124
|
+
*/
|
|
125
|
+
cacheDeltas?: Map<string, number>;
|
|
126
|
+
/** The fleet-wide delta under the same convention. */
|
|
127
|
+
aggregateCacheDelta?: number;
|
|
128
|
+
} = {},
|
|
129
|
+
): FleetRollup {
|
|
130
|
+
const rows = sources
|
|
131
|
+
.map((s) => ({
|
|
132
|
+
name: s.name,
|
|
133
|
+
usd: s.report.total.totalUsd,
|
|
134
|
+
calls: s.report.total.calls,
|
|
135
|
+
spanDays: spanDaysOf(s.report),
|
|
136
|
+
}))
|
|
137
|
+
.sort((a, b) => b.usd - a.usd);
|
|
138
|
+
|
|
139
|
+
const totalUsd = rows.reduce((sum, r) => sum + r.usd, 0);
|
|
140
|
+
const calls = rows.reduce((sum, r) => sum + r.calls, 0);
|
|
141
|
+
const withShare = rows.map((r) => ({
|
|
142
|
+
...r,
|
|
143
|
+
share: totalUsd > 0 ? r.usd / totalUsd : 0,
|
|
144
|
+
}));
|
|
145
|
+
|
|
146
|
+
const spans = rows.map((r) => r.spanDays);
|
|
147
|
+
const known = spans.filter((d): d is number => d !== null);
|
|
148
|
+
const mismatchedSpans =
|
|
149
|
+
spans.some((d) => d === null && known.length > 0) ||
|
|
150
|
+
(known.length > 1 && Math.max(...known) - Math.min(...known) > 1);
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Split brains: one label, different models, different sources. Judged on
|
|
154
|
+
* each source's *dearest* model for the label, so a stray experiment call
|
|
155
|
+
* does not report a team as migrated.
|
|
156
|
+
*/
|
|
157
|
+
const labelModel = new Map<string, Map<string, { model: string; usd: number }>>();
|
|
158
|
+
for (const source of sources) {
|
|
159
|
+
for (const slice of source.report.byLabelAndModel) {
|
|
160
|
+
if (slice.label === UNLABELLED) continue;
|
|
161
|
+
const perSource = labelModel.get(slice.label) ?? new Map();
|
|
162
|
+
const current = perSource.get(source.name);
|
|
163
|
+
if (current === undefined || slice.breakdown.totalUsd > current.usd) {
|
|
164
|
+
perSource.set(source.name, { model: slice.model, usd: slice.breakdown.totalUsd });
|
|
165
|
+
}
|
|
166
|
+
labelModel.set(slice.label, perSource);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const splitBrains: FleetRollup['splitBrains'] = [];
|
|
170
|
+
for (const [label, perSource] of labelModel) {
|
|
171
|
+
if (perSource.size < 2) continue;
|
|
172
|
+
const models = new Set([...perSource.values()].map((v) => v.model));
|
|
173
|
+
if (models.size < 2) continue;
|
|
174
|
+
const list = [...perSource.entries()]
|
|
175
|
+
.map(([name, v]) => ({ name, model: v.model, usd: v.usd }))
|
|
176
|
+
.filter((v) => v.usd > 0)
|
|
177
|
+
.sort((a, b) => b.usd - a.usd);
|
|
178
|
+
if (new Set(list.map((v) => v.model)).size < 2) continue;
|
|
179
|
+
splitBrains.push({ label, sources: list });
|
|
180
|
+
}
|
|
181
|
+
splitBrains.sort(
|
|
182
|
+
(a, b) =>
|
|
183
|
+
b.sources.reduce((s, v) => s + v.usd, 0) - a.sources.reduce((s, v) => s + v.usd, 0),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Cache underwater: only meaningful when the aggregate paid off — when the
|
|
188
|
+
* aggregate itself lost money, the whole-fleet report already shouts and
|
|
189
|
+
* naming each source would repeat it in pieces.
|
|
190
|
+
*/
|
|
191
|
+
const cacheUnderwater: FleetRollup['cacheUnderwater'] = [];
|
|
192
|
+
if (
|
|
193
|
+
options.cacheDeltas !== undefined &&
|
|
194
|
+
options.aggregateCacheDelta !== undefined &&
|
|
195
|
+
options.aggregateCacheDelta <= 0
|
|
196
|
+
) {
|
|
197
|
+
for (const [name, deltaUsd] of options.cacheDeltas) {
|
|
198
|
+
if (deltaUsd > 0) cacheUnderwater.push({ name, deltaUsd });
|
|
199
|
+
}
|
|
200
|
+
cacheUnderwater.sort((a, b) => b.deltaUsd - a.deltaUsd);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
totalUsd,
|
|
205
|
+
calls,
|
|
206
|
+
sources: withShare,
|
|
207
|
+
worst: totalUsd > 0 ? { name: rows[0]!.name, usd: rows[0]!.usd, share: withShare[0]!.share } : null,
|
|
208
|
+
mismatchedSpans,
|
|
209
|
+
splitBrains,
|
|
210
|
+
cacheUnderwater,
|
|
211
|
+
};
|
|
212
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -42,6 +42,10 @@ export { driversBetween } from './against.js';
|
|
|
42
42
|
export type { AgainstDriver } from './against.js';
|
|
43
43
|
export { coverageDrift, COVERAGE_FIELDS, COVERAGE_DRIFT_MIN } from './coverage-drift.js';
|
|
44
44
|
export { explainGateFailure, gateMargin, GATE_MARGIN_TIGHT } from './gate-explain.js';
|
|
45
|
+
export { measuredUsage, labelCoverage, MIN_SCALE_DAYS, SCALE_TO_DAYS } from './measured-profile.js';
|
|
46
|
+
export { assignSources, fleetRollup } from './fleet.js';
|
|
47
|
+
export type { FleetSource, FleetRollup } from './fleet.js';
|
|
48
|
+
export type { MeasuredUsage, LabelCoverage } from './measured-profile.js';
|
|
45
49
|
export type { GateExplanation } from './gate-explain.js';
|
|
46
50
|
export type { CoverageDrift, CoverageField } from './coverage-drift.js';
|
|
47
51
|
// The same tokens at another model's rates — arithmetic, not advice, and it
|