@trazum/cli 1.30.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/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, coverageDrift, 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,16 +1790,60 @@ 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) {
@@ -1877,10 +1934,12 @@ async function commandProfile(args, config, pricing, t) {
1877
1934
  : '';
1878
1935
  if (worst.usd > maxDay) {
1879
1936
  console.error(c.red(`${t.profile.maxDayFailed(worst.day, formatUsd(worst.usd), formatUsd(maxDay))}${suspect}`));
1937
+ explainFailure(worst.usd - maxDay, { namesLargest: true });
1880
1938
  process.exitCode = 1;
1881
1939
  }
1882
1940
  else {
1883
1941
  console.error(c.dim(t.profile.maxDayOk(worst.day, formatUsd(worst.usd), formatUsd(maxDay))));
1942
+ explainMargin(worst.usd, maxDay);
1884
1943
  /**
1885
1944
  * Calls with no clock are in the bill above and in no day below, so
1886
1945
  * the worst day is a floor by exactly that much. Said only on a
@@ -1916,13 +1975,39 @@ async function commandProfile(args, config, pricing, t) {
1916
1975
  }
1917
1976
  else if (report.sessionSpend.maxUsd > maxSession) {
1918
1977
  console.error(c.red(t.profile.maxSessionFailed(formatUsd(report.sessionSpend.maxUsd), formatUsd(maxSession), n(report.sessionSpend.sessions))));
1978
+ explainFailure(report.sessionSpend.maxUsd - maxSession);
1919
1979
  process.exitCode = 1;
1920
1980
  }
1921
1981
  else {
1922
1982
  console.error(c.dim(t.profile.maxSessionOk(formatUsd(report.sessionSpend.maxUsd), formatUsd(maxSession), n(report.sessionSpend.sessions))));
1983
+ explainMargin(report.sessionSpend.maxUsd, maxSession);
1923
1984
  }
1924
1985
  }
1925
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
+ };
1926
2011
  /**
1927
2012
  * The side files the caller asked for. Written on **both** output paths:
1928
2013
  * under --json the human rendering returns early, and the first version of
@@ -1956,6 +2041,9 @@ async function commandProfile(args, config, pricing, t) {
1956
2041
  ? { window: { since: stringFlag(args, 'since') ?? '—', until: stringFlag(args, 'until') ?? '—' } }
1957
2042
  : {}),
1958
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 } } : {}),
1959
2047
  // The repricing, when --what-if was given: computed once above and
1960
2048
  // handed over, so the summary in a pull request cannot disagree
1961
2049
  // with the terminal about what a move would cost.
@@ -2065,8 +2153,8 @@ async function commandProfile(args, config, pricing, t) {
2065
2153
  // without the caveat being in the same object.
2066
2154
  ...(whatIf !== null ? { whatIf } : {}),
2067
2155
  }, null, 2));
2156
+ recordGates();
2068
2157
  await writeSideFiles();
2069
- applyGates();
2070
2158
  return;
2071
2159
  }
2072
2160
  /**
@@ -3054,8 +3142,8 @@ async function commandProfile(args, config, pricing, t) {
3054
3142
  }
3055
3143
  }
3056
3144
  reportProfileGaps(report, t, n, pricingStale);
3145
+ recordGates();
3057
3146
  await writeSideFiles();
3058
- applyGates();
3059
3147
  }
3060
3148
  /**
3061
3149
  * What the profile could not account for, said out loud.