@trazum/cli 1.29.0 → 1.31.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/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +16 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +16 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +36 -0
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.js +146 -4
- package/dist/index.js.map +1 -1
- package/dist/markdown.d.ts +13 -0
- package/dist/markdown.d.ts.map +1 -1
- package/dist/markdown.js +20 -1
- package/dist/markdown.js.map +1 -1
- package/package.json +2 -2
- package/src/i18n/en.ts +24 -0
- package/src/i18n/es.ts +24 -0
- package/src/i18n/types.ts +36 -0
- package/src/index.ts +175 -4
- package/src/markdown.ts +29 -1
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { join, resolve as resolvePath } from 'node:path';
|
|
4
4
|
import { gunzipSync } from 'node:zlib';
|
|
5
|
-
import { applyRewrites, BASELINE_FILENAME, BASELINE_VERSION, breaches, cacheableMinimum, analyzeCachePrefix, billLevers, cacheEconomics, cacheHitRate, contextPressure, comparePrompts, compareToBaseline, computeSavings, countTokensAnthropic, DEFAULT_USAGE, detectFromSource, driversBetween, estimateTokens, evaluate, extractPrompts, findExamples, formatBaseline, formatSignedUsd, formatUsd, getMessages, getModel, hasMarker, LOCALES, MAX_BASELINE_BYTES, moneyIsComparable, mostSpecificMatch, nearestName, optimize, parseBaseline, PHRASE_LANGUAGES, plannedCalls, profilePrompt, profileToCsv, profileUsage, promptId, providerFromEnv, pruneExamples, refineWithLlm, rejectionText, reorderForCache, repriceProfile, reviewAgeDays, reviewExamples, RULES, sharedPrefixes, sharesOf, SOURCE_EXTENSIONS, suggestRewrites, toOtlpMetrics, toPromptfoo, TTL_1H_MS, UNLABELLED, withExactTokenCounts, } from '@trazum/core';
|
|
5
|
+
import { applyRewrites, BASELINE_FILENAME, BASELINE_VERSION, breaches, cacheableMinimum, analyzeCachePrefix, billLevers, cacheEconomics, cacheHitRate, contextPressure, comparePrompts, compareToBaseline, computeSavings, countTokensAnthropic, DEFAULT_USAGE, detectFromSource, coverageDrift, driversBetween, explainGateFailure, gateMargin, GATE_MARGIN_TIGHT, estimateTokens, evaluate, extractPrompts, findExamples, formatBaseline, formatSignedUsd, formatUsd, getMessages, getModel, hasMarker, LOCALES, MAX_BASELINE_BYTES, moneyIsComparable, mostSpecificMatch, nearestName, optimize, parseBaseline, PHRASE_LANGUAGES, plannedCalls, profilePrompt, profileToCsv, profileUsage, promptId, providerFromEnv, pruneExamples, refineWithLlm, rejectionText, reorderForCache, repriceProfile, reviewAgeDays, reviewExamples, RULES, sharedPrefixes, sharesOf, SOURCE_EXTENSIONS, suggestRewrites, toOtlpMetrics, toPromptfoo, TTL_1H_MS, UNLABELLED, withExactTokenCounts, } from '@trazum/core';
|
|
6
6
|
import { cacheDir, cacheStats, cachingProvider, clearCache } from './suggest-cache.js';
|
|
7
7
|
import { dayOf, formatGap, median, spanDays } from './time.js';
|
|
8
8
|
// Everything that reads the filesystem, on its own entry point so the web
|
|
@@ -1718,6 +1718,19 @@ async function commandProfile(args, config, pricing, t) {
|
|
|
1718
1718
|
* profiles yesterday's log has a daily budget without Trazum ever
|
|
1719
1719
|
* guessing what a day is.
|
|
1720
1720
|
*/
|
|
1721
|
+
/**
|
|
1722
|
+
* The gate verdicts, kept so the markdown summary can carry them.
|
|
1723
|
+
*
|
|
1724
|
+
* Collected by wrapping `console.error` for the duration of `applyGates`
|
|
1725
|
+
* rather than by threading a return value through every gate. That is the
|
|
1726
|
+
* unusual choice here and it is deliberate: a gate added later reaches the
|
|
1727
|
+
* summary without anyone remembering to register it, and the alternative —
|
|
1728
|
+
* one push per verdict at a dozen call sites — is a list that goes stale
|
|
1729
|
+
* silently. Colour is stripped, because a summary is markdown and an
|
|
1730
|
+
* escape sequence in it is noise a reader has to look past.
|
|
1731
|
+
*/
|
|
1732
|
+
const gateVerdicts = [];
|
|
1733
|
+
let gateFailed = false;
|
|
1721
1734
|
const applyGates = () => {
|
|
1722
1735
|
/**
|
|
1723
1736
|
* Before any verdict: whether the gated figure is the whole bill. A gate
|
|
@@ -1777,21 +1790,85 @@ async function commandProfile(args, config, pricing, t) {
|
|
|
1777
1790
|
// for a period the caller did not name would gate against a slice.
|
|
1778
1791
|
console.error(c.dim(t.profile.labelBudgetWindowed()));
|
|
1779
1792
|
}
|
|
1793
|
+
/**
|
|
1794
|
+
* Why a gate failed and how much room a pass had — written once, called by
|
|
1795
|
+
* every gate, because four hand-rolled copies of the same three sentences
|
|
1796
|
+
* is four chances for one of them to soften.
|
|
1797
|
+
*/
|
|
1798
|
+
const explainFailure = (overUsd, { namesLargest = false } = {}) => {
|
|
1799
|
+
const why = explainGateFailure(report, levers, overUsd);
|
|
1800
|
+
// The day gate already names its own day's biggest label; repeating the
|
|
1801
|
+
// whole bill's biggest slice under it reads as the same sentence twice.
|
|
1802
|
+
if (why.largest !== null && !namesLargest) {
|
|
1803
|
+
const name = why.largest.label === UNLABELLED ? t.profile.unlabelled() : why.largest.label;
|
|
1804
|
+
console.error(c.dim(wrap(t.profile.gateLargest(name, why.largest.model, formatUsd(why.largest.usd), pct(why.largest.share)), 74, ' ')));
|
|
1805
|
+
}
|
|
1806
|
+
if (why.lever !== null) {
|
|
1807
|
+
const leverName = why.lever.label === UNLABELLED ? t.profile.unlabelled() : why.lever.label;
|
|
1808
|
+
// The action, not the slice's current model: a slice with only a batch
|
|
1809
|
+
// price has no destination, and naming the model it already runs on as
|
|
1810
|
+
// somewhere to move it would be plainly false.
|
|
1811
|
+
const route = why.lever.route;
|
|
1812
|
+
const action = route !== null && why.lever.batch !== null
|
|
1813
|
+
? t.profile.gateLeverBoth(route.candidate.displayName)
|
|
1814
|
+
: route !== null
|
|
1815
|
+
? t.profile.gateLeverRoute(route.candidate.displayName)
|
|
1816
|
+
: t.profile.gateLeverBatch();
|
|
1817
|
+
console.error(c.dim(wrap(t.profile.gateLever(leverName, action, formatUsd(why.lever.combinedUsd), formatUsd(why.overageUsd), why.coversIt), 74, ' ')));
|
|
1818
|
+
}
|
|
1819
|
+
};
|
|
1820
|
+
/** How much room a pass had, said only when tight, threshold in the copy. */
|
|
1821
|
+
const explainMargin = (judgedUsd, limitUsd) => {
|
|
1822
|
+
const margin = gateMargin(judgedUsd, limitUsd);
|
|
1823
|
+
if (margin !== null && margin < GATE_MARGIN_TIGHT) {
|
|
1824
|
+
console.error(c.yellow(wrap(t.profile.gateMarginTight(pct(margin), formatUsd(limitUsd - judgedUsd)), 74, ' ')));
|
|
1825
|
+
}
|
|
1826
|
+
};
|
|
1780
1827
|
if (typeof args.flags.get('max-usd') === 'string' || config.spend?.maxUsd !== undefined) {
|
|
1781
1828
|
const maxUsd = typeof args.flags.get('max-usd') === 'string'
|
|
1782
1829
|
? numberFlag(args, 'max-usd', 0, t)
|
|
1783
1830
|
: config.spend.maxUsd;
|
|
1784
1831
|
if (report.total.totalUsd > maxUsd) {
|
|
1785
1832
|
console.error(c.red(t.profile.maxUsdFailed(formatUsd(report.total.totalUsd), formatUsd(maxUsd))));
|
|
1833
|
+
/**
|
|
1834
|
+
* What to change, next to the fact that something must. A red build in
|
|
1835
|
+
* CI is the one place nobody opens the full report, so the failure
|
|
1836
|
+
* carries its own next step: which slice holds the money, and the one
|
|
1837
|
+
* lever the report already priced. Nothing here is a recommendation —
|
|
1838
|
+
* whether that model can do the work is the reader's to judge, and the
|
|
1839
|
+
* copy says so.
|
|
1840
|
+
*/
|
|
1841
|
+
explainFailure(report.total.totalUsd - maxUsd);
|
|
1786
1842
|
process.exitCode = 1;
|
|
1787
1843
|
}
|
|
1788
1844
|
else {
|
|
1789
1845
|
console.error(c.dim(t.profile.maxUsdOk(formatUsd(report.total.totalUsd), formatUsd(maxUsd))));
|
|
1846
|
+
explainMargin(report.total.totalUsd, maxUsd);
|
|
1790
1847
|
}
|
|
1791
1848
|
}
|
|
1792
1849
|
if (typeof args.flags.get('max-growth-usd') === 'string' && againstDelta !== null) {
|
|
1793
1850
|
const maxGrowth = numberFlag(args, 'max-growth-usd', 0, t);
|
|
1794
|
-
|
|
1851
|
+
/**
|
|
1852
|
+
* A comparison that went blind fails before it is judged.
|
|
1853
|
+
*
|
|
1854
|
+
* The dollars can hold flat while the current log stopped recording a
|
|
1855
|
+
* field the previous one carried — and every finding that needed the
|
|
1856
|
+
* field is now silent for a reason that has nothing to do with spend.
|
|
1857
|
+
* A gate passing there would be certifying a comparison it could not
|
|
1858
|
+
* make: "not measured" is not "did not grow", the same refusal
|
|
1859
|
+
* --max-day-usd makes on a clockless log and --max-session-usd on a
|
|
1860
|
+
* sessionless one. Only a collapse fails; a field that appeared means
|
|
1861
|
+
* this side can see more, which is never a reason to refuse.
|
|
1862
|
+
*/
|
|
1863
|
+
const blinded = previous !== null
|
|
1864
|
+
? coverageDrift(previous.fieldCoverage, report.fieldCoverage).filter((d) => d.delta < 0)
|
|
1865
|
+
: [];
|
|
1866
|
+
const worst = blinded[0];
|
|
1867
|
+
if (worst !== undefined) {
|
|
1868
|
+
console.error(c.red(t.profile.maxGrowthCoverageLost(blinded.map((d) => t.profile.coverageField(d.field)).join(', '), pct(worst.was), pct(worst.now))));
|
|
1869
|
+
process.exitCode = 1;
|
|
1870
|
+
}
|
|
1871
|
+
else if (againstDelta > maxGrowth) {
|
|
1795
1872
|
console.error(c.red(t.profile.maxGrowthUsdFailed(formatSignedUsd(againstDelta), formatUsd(maxGrowth))));
|
|
1796
1873
|
process.exitCode = 1;
|
|
1797
1874
|
}
|
|
@@ -1857,10 +1934,12 @@ async function commandProfile(args, config, pricing, t) {
|
|
|
1857
1934
|
: '';
|
|
1858
1935
|
if (worst.usd > maxDay) {
|
|
1859
1936
|
console.error(c.red(`${t.profile.maxDayFailed(worst.day, formatUsd(worst.usd), formatUsd(maxDay))}${suspect}`));
|
|
1937
|
+
explainFailure(worst.usd - maxDay, { namesLargest: true });
|
|
1860
1938
|
process.exitCode = 1;
|
|
1861
1939
|
}
|
|
1862
1940
|
else {
|
|
1863
1941
|
console.error(c.dim(t.profile.maxDayOk(worst.day, formatUsd(worst.usd), formatUsd(maxDay))));
|
|
1942
|
+
explainMargin(worst.usd, maxDay);
|
|
1864
1943
|
/**
|
|
1865
1944
|
* Calls with no clock are in the bill above and in no day below, so
|
|
1866
1945
|
* the worst day is a floor by exactly that much. Said only on a
|
|
@@ -1896,13 +1975,39 @@ async function commandProfile(args, config, pricing, t) {
|
|
|
1896
1975
|
}
|
|
1897
1976
|
else if (report.sessionSpend.maxUsd > maxSession) {
|
|
1898
1977
|
console.error(c.red(t.profile.maxSessionFailed(formatUsd(report.sessionSpend.maxUsd), formatUsd(maxSession), n(report.sessionSpend.sessions))));
|
|
1978
|
+
explainFailure(report.sessionSpend.maxUsd - maxSession);
|
|
1899
1979
|
process.exitCode = 1;
|
|
1900
1980
|
}
|
|
1901
1981
|
else {
|
|
1902
1982
|
console.error(c.dim(t.profile.maxSessionOk(formatUsd(report.sessionSpend.maxUsd), formatUsd(maxSession), n(report.sessionSpend.sessions))));
|
|
1983
|
+
explainMargin(report.sessionSpend.maxUsd, maxSession);
|
|
1903
1984
|
}
|
|
1904
1985
|
}
|
|
1905
1986
|
};
|
|
1987
|
+
/**
|
|
1988
|
+
* Run the gates, keeping what they said. Exit codes and stderr behave
|
|
1989
|
+
* exactly as before — this only also remembers, so `--markdown-out` can put
|
|
1990
|
+
* the verdict where the person reading CI will actually see it.
|
|
1991
|
+
*/
|
|
1992
|
+
const recordGates = () => {
|
|
1993
|
+
const original = console.error;
|
|
1994
|
+
console.error = (...parts) => {
|
|
1995
|
+
const text = parts.map((part) => String(part)).join(' ');
|
|
1996
|
+
// Colour stripped and the terminal's wrap collapsed: markdown re-wraps
|
|
1997
|
+
// to its own width, and the escape sequences and hanging indents that
|
|
1998
|
+
// make a terminal readable are noise a summary reader looks past.
|
|
1999
|
+
// eslint-disable-next-line no-control-regex
|
|
2000
|
+
gateVerdicts.push(text.replace(/\u001b\[[0-9;]*m/g, '').replace(/\s+/g, ' ').trim());
|
|
2001
|
+
original(...parts);
|
|
2002
|
+
};
|
|
2003
|
+
try {
|
|
2004
|
+
applyGates();
|
|
2005
|
+
}
|
|
2006
|
+
finally {
|
|
2007
|
+
console.error = original;
|
|
2008
|
+
gateFailed = process.exitCode === 1;
|
|
2009
|
+
}
|
|
2010
|
+
};
|
|
1906
2011
|
/**
|
|
1907
2012
|
* The side files the caller asked for. Written on **both** output paths:
|
|
1908
2013
|
* under --json the human rendering returns early, and the first version of
|
|
@@ -1936,6 +2041,9 @@ async function commandProfile(args, config, pricing, t) {
|
|
|
1936
2041
|
? { window: { since: stringFlag(args, 'since') ?? '—', until: stringFlag(args, 'until') ?? '—' } }
|
|
1937
2042
|
: {}),
|
|
1938
2043
|
...(pricingStale !== null ? { stalePricing: pricingStale } : {}),
|
|
2044
|
+
// The verdict, where the person reading CI will see it. recordGates()
|
|
2045
|
+
// runs before the side files for exactly this.
|
|
2046
|
+
...(gateVerdicts.length > 0 ? { gates: { failed: gateFailed, lines: gateVerdicts } } : {}),
|
|
1939
2047
|
// The repricing, when --what-if was given: computed once above and
|
|
1940
2048
|
// handed over, so the summary in a pull request cannot disagree
|
|
1941
2049
|
// with the terminal about what a move would cost.
|
|
@@ -2045,8 +2153,8 @@ async function commandProfile(args, config, pricing, t) {
|
|
|
2045
2153
|
// without the caveat being in the same object.
|
|
2046
2154
|
...(whatIf !== null ? { whatIf } : {}),
|
|
2047
2155
|
}, null, 2));
|
|
2156
|
+
recordGates();
|
|
2048
2157
|
await writeSideFiles();
|
|
2049
|
-
applyGates();
|
|
2050
2158
|
return;
|
|
2051
2159
|
}
|
|
2052
2160
|
/**
|
|
@@ -2945,6 +3053,40 @@ async function commandProfile(args, config, pricing, t) {
|
|
|
2945
3053
|
console.log(` ${d.delta > 0 ? c.yellow(line) : c.dim(line)}`);
|
|
2946
3054
|
}
|
|
2947
3055
|
}
|
|
3056
|
+
/**
|
|
3057
|
+
* What the comparison stopped being able to see.
|
|
3058
|
+
*
|
|
3059
|
+
* Every figure above is dollars, and dollars cannot tell a finding that
|
|
3060
|
+
* was fixed from a finding whose field the log stopped recording — both
|
|
3061
|
+
* are silence. This is the only section that can, so it is loud: a
|
|
3062
|
+
* collapse in coverage invalidates whichever findings depended on it,
|
|
3063
|
+
* and reading the drop as good news is the specific mistake it exists
|
|
3064
|
+
* to prevent.
|
|
3065
|
+
*/
|
|
3066
|
+
const drifts = coverageDrift(previous.fieldCoverage, report.fieldCoverage);
|
|
3067
|
+
if (drifts.length > 0) {
|
|
3068
|
+
console.log();
|
|
3069
|
+
for (const drift of drifts) {
|
|
3070
|
+
const line = t.profile.coverageDrift(t.profile.coverageField(drift.field), pct(drift.was), pct(drift.now));
|
|
3071
|
+
console.log(drift.delta < 0
|
|
3072
|
+
? ` ${c.yellow('!')} ${c.bold(wrap(line, 74, ' '))}`
|
|
3073
|
+
: ` ${c.dim(wrap(line, 74, ' '))}`);
|
|
3074
|
+
/**
|
|
3075
|
+
* Which findings went with it, named. "Some findings are silent" is
|
|
3076
|
+
* not something a reader can act on; knowing that conversation
|
|
3077
|
+
* growth and the cache-TTL fit are now silence rather than absence
|
|
3078
|
+
* tells them exactly which sections of this report to distrust.
|
|
3079
|
+
*/
|
|
3080
|
+
if (drift.delta < 0) {
|
|
3081
|
+
const silenced = t.profile.coverageSilenced(drift.field);
|
|
3082
|
+
if (silenced !== '')
|
|
3083
|
+
console.log(` ${c.dim(wrap(silenced, 72, ' '))}`);
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
if (drifts.some((d) => d.delta < 0)) {
|
|
3087
|
+
console.log(` ${c.dim(wrap(t.profile.coverageDriftWhy(), 74, ' '))}`);
|
|
3088
|
+
}
|
|
3089
|
+
}
|
|
2948
3090
|
}
|
|
2949
3091
|
}
|
|
2950
3092
|
for (const [heading, rows] of [
|
|
@@ -3000,8 +3142,8 @@ async function commandProfile(args, config, pricing, t) {
|
|
|
3000
3142
|
}
|
|
3001
3143
|
}
|
|
3002
3144
|
reportProfileGaps(report, t, n, pricingStale);
|
|
3145
|
+
recordGates();
|
|
3003
3146
|
await writeSideFiles();
|
|
3004
|
-
applyGates();
|
|
3005
3147
|
}
|
|
3006
3148
|
/**
|
|
3007
3149
|
* What the profile could not account for, said out loud.
|