@trazum/cli 1.36.0 → 1.38.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, explainGateFailure, labelCoverage, measuredUsage, 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';
5
+ import { applyRewrites, BASELINE_FILENAME, BASELINE_VERSION, breaches, cacheableMinimum, analyzeCachePrefix, billLevers, buildPlan, cacheEconomics, cacheHitRate, contextPressure, comparePrompts, compareToBaseline, computeSavings, countTokensAnthropic, DEFAULT_USAGE, detectFromSource, coverageDrift, driversBetween, explainGateFailure, assignSources, fleetRollup, labelCoverage, measuredUsage, 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
@@ -26,6 +26,7 @@ const c = {
26
26
  const VALUE_FLAGS = new Set([
27
27
  'against',
28
28
  'from-log',
29
+ 'min-usd',
29
30
  // `route` takes a path here, and the flag is deliberately not `--prompt`:
30
31
  // everywhere else in this tool `--prompt` names a marked prompt *inside* a
31
32
  // source file, and reusing it for a path would be a trap laid for the reader.
@@ -159,6 +160,12 @@ function levelFlag(args, config, t) {
159
160
  * model id. It beats the default because reading the code is better than
160
161
  * assuming, and loses to config because being told is better than reading.
161
162
  */
163
+ /**
164
+ * The file names a usage log answers to, shared by every command that reads a
165
+ * directory of them. One list, because two commands disagreeing on what counts
166
+ * as a log would be the same directory billing differently by verb.
167
+ */
168
+ const LOG_EXTENSIONS = ['.jsonl', '.ndjson', '.log', '.json'];
162
169
  /**
163
170
  * One usage log, gzip included, shared by every command that reads one.
164
171
  *
@@ -278,7 +285,8 @@ const COMMAND_FLAGS = {
278
285
  ],
279
286
  check: ['max-tokens', 'level', 'exact-tokens', 'markdown-out', 'baseline'],
280
287
  baseline: ['model', 'calls', 'output-tokens', 'cache-hit-rate', 'batch', 'exact-tokens', 'out', 'o'],
281
- profile: ['json', 'pricing', 'pricing-live', 'against', 'what-if', 'markdown-out', 'csv-out', 'csv-shape', 'max-usd', 'max-growth-usd', 'max-cache-loss-usd', 'max-day-usd', 'max-session-usd', 'label', 'since', 'until', 'dry-run', 'markdown-summary'],
288
+ profile: ['json', 'pricing', 'pricing-live', 'against', 'what-if', 'markdown-out', 'csv-out', 'csv-shape', 'max-usd', 'max-growth-usd', 'max-cache-loss-usd', 'max-day-usd', 'max-session-usd', 'label', 'since', 'until', 'dry-run', 'markdown-summary', 'by-source'],
289
+ plan: ['json', 'out', 'markdown-out', 'min-usd', 'pricing', 'pricing-live'],
282
290
  route: ['prompt-file', 'cases', 'label', 'concurrency', 'json', 'yes', 'pricing', 'pricing-live'],
283
291
  eval: ['cases', 'level', 'concurrency', 'export', 'out', 'o', 'model'],
284
292
  prune: ['cases', 'concurrency', 'json', 'yes'],
@@ -1636,6 +1644,130 @@ function isoDate() {
1636
1644
  * metered API calls somebody was actually billed for — the bill exists wherever
1637
1645
  * Trazum happens to be running, so the host has no bearing on it.
1638
1646
  */
1647
+ /**
1648
+ * `trazum plan <log>` — not a list of findings, a ranked plan of what to do.
1649
+ *
1650
+ * The composition (route and batch on one slice never summed) happens in
1651
+ * core's `buildPlan`; this command owns the I/O and the rendering. The plan
1652
+ * saves as a dated JSON file on request, which is what makes verifying it
1653
+ * against a later log possible at all — a prediction nobody wrote down is a
1654
+ * prediction nobody can be held to.
1655
+ */
1656
+ async function commandPlan(args, pricing, t) {
1657
+ const path = args.positional[0];
1658
+ if (path === undefined)
1659
+ throw new Error(t.plan.noTarget());
1660
+ const GZ = LOG_EXTENSIONS.map((ext) => `${ext}.gz`);
1661
+ const READABLE = [...LOG_EXTENSIONS, ...GZ];
1662
+ const target = await stat(path).catch(() => null);
1663
+ let files = [path];
1664
+ if (target?.isDirectory()) {
1665
+ const entries = await readdir(path, { withFileTypes: true });
1666
+ files = entries
1667
+ .filter((entry) => entry.isFile() && READABLE.some((ext) => entry.name.endsWith(ext)))
1668
+ .map((entry) => join(path, entry.name))
1669
+ .sort((a, b) => a.localeCompare(b));
1670
+ if (files.length === 0)
1671
+ throw new Error(t.profile.noLogsInDirectory(path, READABLE.join(', ')));
1672
+ }
1673
+ const texts = await Promise.all(files.map((file) => readUsageLog(file, t)));
1674
+ const raw = texts.map((text) => (text.endsWith('\n') ? text : `${text}\n`)).join('');
1675
+ const report = profileUsage(raw, { catalogue: pricing });
1676
+ if (report.total.calls === 0)
1677
+ throw new Error(t.plan.nothingPriced());
1678
+ const levers = billLevers(report, { catalogue: pricing });
1679
+ const plan = buildPlan(report, levers, pricing.lastReviewed);
1680
+ const minUsd = typeof args.flags.get('min-usd') === 'string' ? numberFlag(args, 'min-usd', 0, t) : 0;
1681
+ const actions = plan.actions.filter((a) => (a.savingUsd ?? a.stakeUsd ?? 0) >= minUsd);
1682
+ const filtered = plan.actions.length - actions.length;
1683
+ const droppedUsd = plan.actions
1684
+ .filter((a) => (a.savingUsd ?? a.stakeUsd ?? 0) < minUsd)
1685
+ .reduce((sum, a) => sum + (a.savingUsd ?? a.stakeUsd ?? 0), 0);
1686
+ const n = (value) => value.toLocaleString(t.numberLocale);
1687
+ /**
1688
+ * The document's totals cover the actions the document holds — a filtered
1689
+ * plan whose totals still counted the filtered actions would be a file
1690
+ * that contradicts itself, and 1.39's verify would hold it to money it
1691
+ * cannot see. What --min-usd dropped is stated with its worth, never
1692
+ * silently.
1693
+ */
1694
+ const stamped = {
1695
+ ...plan,
1696
+ actions,
1697
+ projectedSavingUsd: actions.reduce((sum, a) => sum + (a.savingUsd ?? 0), 0),
1698
+ measuredStakeUsd: actions.reduce((sum, a) => sum + (a.stakeUsd ?? 0), 0),
1699
+ createdAt: new Date().toISOString(),
1700
+ };
1701
+ const outPath = stringFlag(args, 'out');
1702
+ if (outPath !== undefined) {
1703
+ await writeFile(outPath, `${JSON.stringify(stamped, null, 2)}\n`);
1704
+ }
1705
+ await writeMarkdown(args, () => {
1706
+ const lines = [];
1707
+ lines.push(`## ${t.plan.heading(n(actions.length), formatUsd(plan.totalUsd))}`);
1708
+ lines.push('');
1709
+ lines.push(t.plan.totals(formatUsd(stamped.projectedSavingUsd), formatUsd(stamped.measuredStakeUsd)));
1710
+ if (plan.span === null) {
1711
+ lines.push('');
1712
+ lines.push(`_${t.plan.noClock()}_`);
1713
+ }
1714
+ for (const action of actions) {
1715
+ const name = action.label === UNLABELLED ? t.profile.unlabelled() : action.label;
1716
+ const money = action.savingUsd !== null
1717
+ ? t.plan.projected(formatUsd(action.savingUsd))
1718
+ : t.plan.staked(formatUsd(action.stakeUsd ?? 0));
1719
+ lines.push('');
1720
+ lines.push(`### ${t.plan.action(action.kind, name, action.model)} — ${money}`);
1721
+ if (action.detail.routeTo !== undefined)
1722
+ lines.push(`- ${t.plan.routeTo(action.detail.routeTo.displayName)}`);
1723
+ for (const assumption of action.assumes)
1724
+ lines.push(`- ${t.plan.assume(assumption)}`);
1725
+ if (action.check !== null)
1726
+ lines.push(`- ${t.plan.check(action.check)}`);
1727
+ }
1728
+ if (filtered > 0) {
1729
+ lines.push('');
1730
+ lines.push(`_${t.plan.filtered(n(filtered), formatUsd(minUsd), formatUsd(droppedUsd))}_`);
1731
+ }
1732
+ lines.push('');
1733
+ lines.push(`_${t.plan.footer()}_`);
1734
+ return lines.join('\n');
1735
+ });
1736
+ if (boolFlag(args, 'json')) {
1737
+ console.log(JSON.stringify(stamped, null, 2));
1738
+ return;
1739
+ }
1740
+ console.log(c.bold(t.plan.heading(n(actions.length), formatUsd(plan.totalUsd))));
1741
+ console.log(` ${wrap(t.plan.totals(formatUsd(stamped.projectedSavingUsd), formatUsd(stamped.measuredStakeUsd)), 74, ' ')}`);
1742
+ if (plan.span === null) {
1743
+ console.log(` ${c.dim(wrap(t.plan.noClock(), 74, ' '))}`);
1744
+ }
1745
+ for (const action of actions) {
1746
+ const name = action.label === UNLABELLED ? t.profile.unlabelled() : action.label;
1747
+ const money = action.savingUsd !== null
1748
+ ? t.plan.projected(formatUsd(action.savingUsd))
1749
+ : t.plan.staked(formatUsd(action.stakeUsd ?? 0));
1750
+ console.log();
1751
+ console.log(` ${c.green('→')} ${c.bold(t.plan.action(action.kind, name, action.model))} ${money}`);
1752
+ if (action.detail.routeTo !== undefined) {
1753
+ console.log(` ${c.dim(t.plan.routeTo(action.detail.routeTo.displayName))}`);
1754
+ }
1755
+ for (const assumption of action.assumes) {
1756
+ console.log(` ${c.yellow('?')} ${c.dim(wrap(t.plan.assume(assumption), 72, ' '))}`);
1757
+ }
1758
+ if (action.check !== null) {
1759
+ console.log(` ${c.dim(wrap(t.plan.check(action.check), 72, ' '))}`);
1760
+ }
1761
+ }
1762
+ if (filtered > 0) {
1763
+ console.log();
1764
+ console.log(` ${c.dim(wrap(t.plan.filtered(n(filtered), formatUsd(minUsd), formatUsd(droppedUsd)), 74, ' '))}`);
1765
+ }
1766
+ console.log();
1767
+ console.log(` ${c.dim(wrap(t.plan.footer(), 74, ' '))}`);
1768
+ if (outPath !== undefined)
1769
+ console.log(c.dim(wrap(t.plan.wrote(outPath), 74, '')));
1770
+ }
1639
1771
  async function commandProfile(args, config, pricing, t) {
1640
1772
  const path = args.positional[0];
1641
1773
  if (path === undefined) {
@@ -1658,7 +1790,6 @@ async function commandProfile(args, config, pricing, t) {
1658
1790
  * directory holding nothing readable is an error naming what it looked for,
1659
1791
  * not an empty report.
1660
1792
  */
1661
- const LOG_EXTENSIONS = ['.jsonl', '.ndjson', '.log', '.json'];
1662
1793
  /**
1663
1794
  * The same names, gzipped — which is what a rotated log actually looks like
1664
1795
  * a day after it rotates.
@@ -1675,10 +1806,17 @@ async function commandProfile(args, config, pricing, t) {
1675
1806
  const target = await stat(path).catch(() => null);
1676
1807
  let logFiles = [path];
1677
1808
  if (target?.isDirectory()) {
1678
- const entries = await readdir(path, { withFileTypes: true });
1809
+ /**
1810
+ * Recursive under `--by-source`, flat otherwise. The fleet's whole point
1811
+ * is one directory per service, so the walk must descend; the flat mode
1812
+ * keeps its long-standing behaviour because a directory of rotated logs
1813
+ * with an unrelated subfolder should not quietly absorb it.
1814
+ */
1815
+ const bySourceMode = boolFlag(args, 'by-source');
1816
+ const entries = await readdir(path, { withFileTypes: true, recursive: bySourceMode });
1679
1817
  logFiles = entries
1680
1818
  .filter((entry) => entry.isFile() && READABLE.some((ext) => entry.name.endsWith(ext)))
1681
- .map((entry) => join(path, entry.name))
1819
+ .map((entry) => join(entry.parentPath ?? path, entry.name))
1682
1820
  .sort((a, b) => a.localeCompare(b));
1683
1821
  if (logFiles.length === 0) {
1684
1822
  throw new Error(t.profile.noLogsInDirectory(path, READABLE.join(', ')));
@@ -1801,6 +1939,109 @@ async function commandProfile(args, config, pricing, t) {
1801
1939
  }
1802
1940
  const n = (value) => value.toLocaleString(t.numberLocale);
1803
1941
  const pct = (share) => `${(share * 100).toFixed(1)}%`;
1942
+ /**
1943
+ * `--by-source`: one report per service, plus the rollup — the fleet.
1944
+ *
1945
+ * A merged bill is right for one service and wrong for twelve: it hides
1946
+ * which service the money comes from, per-service budgets cannot exist,
1947
+ * and the findings a comparison between services could make are invisible.
1948
+ * Files are assigned to sources by the most specific matching glob from the
1949
+ * config's `sources` block; a file matching no source is named loudly,
1950
+ * because a log that silently joined no report is spend missing from every
1951
+ * bill.
1952
+ */
1953
+ if (boolFlag(args, 'by-source')) {
1954
+ const sourceDefs = config.sources;
1955
+ if (sourceDefs === undefined || Object.keys(sourceDefs).length === 0) {
1956
+ throw new Error(t.profile.bySourceNeedsConfig());
1957
+ }
1958
+ const { bySource, unmatched } = assignSources(logFiles, sourceDefs);
1959
+ if (bySource.size === 0) {
1960
+ throw new Error(t.profile.bySourceNothingMatched(Object.keys(sourceDefs).join(', ')));
1961
+ }
1962
+ const textByFile = new Map(logFiles.map((file, i) => [file, logTexts[i]]));
1963
+ const fleetSources = [];
1964
+ const cacheDeltas = new Map();
1965
+ for (const [name, files] of [...bySource.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
1966
+ const text = files
1967
+ .map((file) => textByFile.get(file))
1968
+ .map((chunk) => (chunk.endsWith('\n') ? chunk : `${chunk}\n`))
1969
+ .join('');
1970
+ const sourceReport = profileUsage(text, { catalogue: pricing, label: onlyLabel, sinceMs, untilMs });
1971
+ fleetSources.push({ name, report: sourceReport });
1972
+ cacheDeltas.set(name, cacheEconomics(sourceReport.total).deltaUsd);
1973
+ }
1974
+ const aggregate = profileUsage(raw, { catalogue: pricing, label: onlyLabel, sinceMs, untilMs });
1975
+ const rollup = fleetRollup(fleetSources, {
1976
+ cacheDeltas,
1977
+ aggregateCacheDelta: cacheEconomics(aggregate.total).deltaUsd,
1978
+ });
1979
+ if (boolFlag(args, 'json')) {
1980
+ console.log(JSON.stringify({
1981
+ schemaVersion: 1,
1982
+ bySource: fleetSources.map((source) => ({ name: source.name, report: source.report })),
1983
+ rollup: {
1984
+ totalUsd: rollup.totalUsd,
1985
+ calls: rollup.calls,
1986
+ sources: rollup.sources,
1987
+ worst: rollup.worst,
1988
+ mismatchedSpans: rollup.mismatchedSpans,
1989
+ splitBrains: rollup.splitBrains,
1990
+ cacheUnderwater: rollup.cacheUnderwater,
1991
+ unmatchedFiles: unmatched,
1992
+ },
1993
+ }, (key, value) => (value instanceof Map ? undefined : value), 2));
1994
+ }
1995
+ else {
1996
+ console.log(c.bold(t.profile.fleetHeading(n(rollup.sources.length), formatUsd(rollup.totalUsd), t.profile.calls(rollup.calls))));
1997
+ for (const row of rollup.sources) {
1998
+ const span = row.spanDays === null ? t.profile.fleetNoClock() : t.profile.fleetSpan(row.spanDays.toFixed(1));
1999
+ console.log(` ${t.profile.fleetRow(row.name, formatUsd(row.usd), pct(row.share), t.profile.calls(row.calls), span)}`);
2000
+ }
2001
+ if (rollup.worst !== null && rollup.sources.length > 1) {
2002
+ console.log();
2003
+ console.log(` ${c.yellow('!')} ${c.bold(wrap(t.profile.fleetWorst(rollup.worst.name, formatUsd(rollup.worst.usd), pct(rollup.worst.share)), 74, ' '))}`);
2004
+ }
2005
+ if (rollup.mismatchedSpans) {
2006
+ console.log(` ${c.dim(wrap(t.profile.fleetMismatchedSpans(), 74, ' '))}`);
2007
+ }
2008
+ for (const split of rollup.splitBrains.slice(0, 3)) {
2009
+ console.log();
2010
+ console.log(` ${c.yellow('!')} ${wrap(t.profile.fleetSplitBrain(split.label, split.sources.map((v) => `${v.name} → ${v.model} (${formatUsd(v.usd)})`).join(', ')), 74, ' ')}`);
2011
+ }
2012
+ for (const under of rollup.cacheUnderwater.slice(0, 3)) {
2013
+ console.log(` ${c.yellow('!')} ${wrap(t.profile.fleetCacheUnderwater(under.name, formatUsd(under.deltaUsd)), 74, ' ')}`);
2014
+ }
2015
+ for (const file of unmatched) {
2016
+ console.log(` ${c.yellow('!')} ${wrap(t.profile.fleetUnmatched(file), 74, ' ')}`);
2017
+ }
2018
+ console.log();
2019
+ console.log(` ${c.dim(wrap(t.profile.fleetFooter(), 74, ' '))}`);
2020
+ }
2021
+ /**
2022
+ * The per-source gates. Each budget judges its own service and the run
2023
+ * fails naming the service — a total that hides which source crossed its
2024
+ * line is the rendering this mode exists to end. Waivable per source
2025
+ * through `bySource:<name>`, under the same expiry discipline.
2026
+ */
2027
+ const bySourceBudgets = config.spend?.bySource ?? {};
2028
+ for (const [name, limit] of Object.entries(bySourceBudgets)) {
2029
+ const found = fleetSources.find((source) => source.name === name);
2030
+ if (found === undefined) {
2031
+ console.error(c.dim(t.profile.fleetBudgetMissing(name)));
2032
+ continue;
2033
+ }
2034
+ const usd = found.report.total.totalUsd;
2035
+ if (usd > limit) {
2036
+ console.error(c.red(t.profile.fleetBudgetFailed(name, formatUsd(usd), formatUsd(limit))));
2037
+ process.exitCode = 1;
2038
+ }
2039
+ else {
2040
+ console.error(c.dim(t.profile.fleetBudgetOk(name, formatUsd(usd), formatUsd(limit))));
2041
+ }
2042
+ }
2043
+ return;
2044
+ }
1804
2045
  /**
1805
2046
  * `--dry-run`: what this log could and could not answer, and no bill.
1806
2047
  *
@@ -4691,6 +4932,9 @@ async function main() {
4691
4932
  case 'profile':
4692
4933
  await commandProfile(args, config, pricing, t);
4693
4934
  break;
4935
+ case 'plan':
4936
+ await commandPlan(args, pricing, t);
4937
+ break;
4694
4938
  case 'route':
4695
4939
  await commandRoute(args, pricing, t);
4696
4940
  break;