@trazum/cli 1.38.0 → 1.40.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, 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';
5
+ import { applyRewrites, BASELINE_FILENAME, BASELINE_VERSION, breaches, cacheableMinimum, analyzeCachePrefix, billLevers, buildHistory, buildPlan, storedReportFrom, verifyPlan, 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
@@ -287,6 +287,8 @@ const COMMAND_FLAGS = {
287
287
  baseline: ['model', 'calls', 'output-tokens', 'cache-hit-rate', 'batch', 'exact-tokens', 'out', 'o'],
288
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
289
  plan: ['json', 'out', 'markdown-out', 'min-usd', 'pricing', 'pricing-live'],
290
+ verify: ['against', 'gate', 'json', 'markdown-out', 'pricing', 'pricing-live'],
291
+ history: ['json', 'markdown-out'],
290
292
  route: ['prompt-file', 'cases', 'label', 'concurrency', 'json', 'yes', 'pricing', 'pricing-live'],
291
293
  eval: ['cases', 'level', 'concurrency', 'export', 'out', 'o', 'model'],
292
294
  prune: ['cases', 'concurrency', 'json', 'yes'],
@@ -1644,6 +1646,224 @@ function isoDate() {
1644
1646
  * metered API calls somebody was actually billed for — the bill exists wherever
1645
1647
  * Trazum happens to be running, so the host has no bearing on it.
1646
1648
  */
1649
+ /**
1650
+ * `trazum history <dir>` — many reports over many periods, as one series.
1651
+ *
1652
+ * Derived from *stored* `--json` documents, never re-parsed logs: a team can
1653
+ * keep a year of reports and throw the raw logs away, which is what the
1654
+ * privacy story requires anyway. Shapes are named — a climb, a decay, the
1655
+ * same action planned twice — and no series, however long, becomes a
1656
+ * forecast.
1657
+ */
1658
+ async function commandHistory(args, t) {
1659
+ const path = args.positional[0];
1660
+ if (path === undefined)
1661
+ throw new Error(t.history.noTarget());
1662
+ const target = await stat(path).catch(() => null);
1663
+ if (!target?.isDirectory())
1664
+ throw new Error(t.history.noTarget());
1665
+ const entries = await readdir(path, { withFileTypes: true });
1666
+ const files = entries
1667
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
1668
+ .map((entry) => join(path, entry.name))
1669
+ .sort((a, b) => a.localeCompare(b));
1670
+ const reports = [];
1671
+ const plans = [];
1672
+ const unrecognized = [];
1673
+ for (const file of files) {
1674
+ let parsed;
1675
+ try {
1676
+ parsed = JSON.parse(await readFile(file, 'utf8'));
1677
+ }
1678
+ catch {
1679
+ unrecognized.push(file);
1680
+ continue;
1681
+ }
1682
+ const report = storedReportFrom(file, parsed);
1683
+ if (report !== null) {
1684
+ reports.push(report);
1685
+ continue;
1686
+ }
1687
+ const maybePlan = parsed;
1688
+ if (maybePlan?.schemaVersion === 1 && Array.isArray(maybePlan.actions)) {
1689
+ plans.push(maybePlan);
1690
+ continue;
1691
+ }
1692
+ unrecognized.push(file);
1693
+ }
1694
+ const history = buildHistory(reports, plans);
1695
+ if (history.periods.length < 3) {
1696
+ throw new Error(t.history.needsThree(String(history.periods.length)));
1697
+ }
1698
+ const stamped = { ...history, unrecognizedFiles: unrecognized };
1699
+ const n = (value) => value.toLocaleString(t.numberLocale);
1700
+ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
1701
+ const pct = (value) => `${(value * 100).toFixed(1)}%`;
1702
+ const runLine = (run) => {
1703
+ if (run.kind === 'label-spend-climbing') {
1704
+ const name = run.subject === UNLABELLED ? t.profile.unlabelled() : run.subject;
1705
+ return t.history.runLabel(name, n(run.periods), run.sinceName, formatUsd(run.from), formatUsd(run.to));
1706
+ }
1707
+ if (run.kind === 'model-share-climbing') {
1708
+ return t.history.runModel(run.subject, n(run.periods), run.sinceName, pct(run.from), pct(run.to));
1709
+ }
1710
+ return t.history.runCache(n(run.periods), run.sinceName, pct(run.from), pct(run.to));
1711
+ };
1712
+ const lines = (md) => {
1713
+ const out = [];
1714
+ const first = history.periods[0];
1715
+ const last = history.periods[history.periods.length - 1];
1716
+ const heading = t.history.heading(n(history.periods.length), day(first.fromMs), day(last.toMs));
1717
+ out.push(md ? `## ${heading}` : heading);
1718
+ for (const period of history.periods) {
1719
+ const row = t.history.periodRow(period.name, formatUsd(period.totalUsd), n(period.calls), ((period.toMs - period.fromMs) / 86_400_000).toFixed(1));
1720
+ out.push(md ? `- ${row}` : ` ${row}`);
1721
+ }
1722
+ if (history.runs.length > 0)
1723
+ out.push('');
1724
+ for (const run of history.runs) {
1725
+ out.push(md ? `- ${runLine(run)}` : ` ! ${runLine(run)}`);
1726
+ }
1727
+ if (history.repeatedPlanActions.length > 0)
1728
+ out.push('');
1729
+ for (const repeat of history.repeatedPlanActions) {
1730
+ const name = repeat.label === UNLABELLED ? t.profile.unlabelled() : repeat.label;
1731
+ const row = t.history.repeated(repeat.kind, name, repeat.model, n(repeat.appearances), repeat.firstPlanned?.slice(0, 10) ?? null, repeat.lastPlanned?.slice(0, 10) ?? null);
1732
+ out.push(md ? `- ${row}` : ` ! ${row}`);
1733
+ }
1734
+ for (const name of history.undatedReports) {
1735
+ out.push(md ? `- ${t.history.undated(name)}` : ` ${t.history.undated(name)}`);
1736
+ }
1737
+ for (const name of unrecognized) {
1738
+ out.push(md ? `- ${t.history.unrecognized(name)}` : ` ${t.history.unrecognized(name)}`);
1739
+ }
1740
+ out.push('');
1741
+ out.push(md ? `_${t.history.footer()}_` : ` ${t.history.footer()}`);
1742
+ return out;
1743
+ };
1744
+ await writeMarkdown(args, () => lines(true).join('\n'));
1745
+ if (boolFlag(args, 'json')) {
1746
+ console.log(JSON.stringify(stamped, null, 2));
1747
+ return;
1748
+ }
1749
+ const [head, ...rest] = lines(false);
1750
+ console.log(c.bold(head));
1751
+ for (const row of rest)
1752
+ console.log(row === '' ? '' : wrap(row, 76, ' '));
1753
+ }
1754
+ /**
1755
+ * `trazum verify <plan.json> --against <newer.jsonl|dir>` — did it work?
1756
+ *
1757
+ * The plan predicted; this holds the prediction to the log that came after
1758
+ * it. Three outcomes and never two — arrived, did not arrive, cannot be told
1759
+ * — because "cannot be told" rendered as "arrived" is how every other tool
1760
+ * congratulates a team for a workload that merely vanished. With `--gate`,
1761
+ * a broken promise is a failing exit code: a different and more useful gate
1762
+ * than "spend went up".
1763
+ */
1764
+ async function commandVerify(args, pricing, t) {
1765
+ const planPath = args.positional[0];
1766
+ if (planPath === undefined)
1767
+ throw new Error(t.verify.noTarget());
1768
+ const againstPath = stringFlag(args, 'against');
1769
+ if (againstPath === undefined)
1770
+ throw new Error(t.verify.needsAgainst());
1771
+ let plan;
1772
+ try {
1773
+ const parsed = JSON.parse(await readFile(planPath, 'utf8'));
1774
+ if (parsed?.schemaVersion !== 1 || !Array.isArray(parsed.actions)) {
1775
+ throw new Error(t.verify.badPlan(planPath));
1776
+ }
1777
+ plan = parsed;
1778
+ }
1779
+ catch (error) {
1780
+ if (error instanceof SyntaxError)
1781
+ throw new Error(t.verify.badPlan(planPath));
1782
+ throw error;
1783
+ }
1784
+ const GZ = LOG_EXTENSIONS.map((ext) => `${ext}.gz`);
1785
+ const READABLE = [...LOG_EXTENSIONS, ...GZ];
1786
+ const target = await stat(againstPath).catch(() => null);
1787
+ let files = [againstPath];
1788
+ if (target?.isDirectory()) {
1789
+ const entries = await readdir(againstPath, { withFileTypes: true });
1790
+ files = entries
1791
+ .filter((entry) => entry.isFile() && READABLE.some((ext) => entry.name.endsWith(ext)))
1792
+ .map((entry) => join(againstPath, entry.name))
1793
+ .sort((a, b) => a.localeCompare(b));
1794
+ if (files.length === 0)
1795
+ throw new Error(t.profile.noLogsInDirectory(againstPath, READABLE.join(', ')));
1796
+ }
1797
+ const texts = await Promise.all(files.map((file) => readUsageLog(file, t)));
1798
+ const raw = texts.map((text) => (text.endsWith('\n') ? text : `${text}\n`)).join('');
1799
+ const report = profileUsage(raw, { catalogue: pricing });
1800
+ const verification = verifyPlan(plan, report, { currentPricingLastReviewed: pricing.lastReviewed });
1801
+ const gate = boolFlag(args, 'gate');
1802
+ const n = (value) => value.toLocaleString(t.numberLocale);
1803
+ const lines = (md) => {
1804
+ const out = [];
1805
+ const actionLine = (v) => {
1806
+ const name = v.action.label === UNLABELLED ? t.profile.unlabelled() : v.action.label;
1807
+ const rows = [];
1808
+ rows.push(t.verify.action(v.action.kind, name, v.action.model, v.outcome));
1809
+ if (v.outcome === 'cannot-tell' && v.reason !== null)
1810
+ rows.push(t.verify.reason(v.reason));
1811
+ if (v.action.kind === 'route' || v.action.kind === 'route+batch') {
1812
+ if (v.outcome !== 'cannot-tell') {
1813
+ rows.push(t.verify.routeObserved(String(v.observed.dearestModel ?? ''), formatUsd(Number(v.observed.onTargetUsd ?? 0)), formatUsd(Number(v.observed.onOldModelUsd ?? 0))));
1814
+ }
1815
+ if (v.action.kind === 'route+batch' && v.outcome !== 'cannot-tell')
1816
+ rows.push(t.verify.batchUnobservable());
1817
+ }
1818
+ if (v.action.kind === 'fix-truncation' && v.outcome === 'not-arrived') {
1819
+ rows.push(t.verify.truncationObserved(formatUsd(Number(v.observed.retryBillUsd ?? 0))));
1820
+ }
1821
+ if (v.action.kind === 'fix-caching' && v.outcome !== 'cannot-tell') {
1822
+ rows.push(t.verify.cacheObserved(formatUsd(Number(v.observed.deltaUsd ?? 0)), v.outcome));
1823
+ }
1824
+ if (v.attribution?.calls !== undefined) {
1825
+ rows.push(t.verify.attribution(n(Math.round(v.attribution.calls.before)), n(Math.round(v.attribution.calls.after)), n(Math.round(v.attribution.outputPerCallTokens?.before ?? 0)), n(Math.round(v.attribution.outputPerCallTokens?.after ?? 0))));
1826
+ }
1827
+ return rows;
1828
+ };
1829
+ const heading = t.verify.heading(n(verification.actions.length), verification.planCreatedAt === null ? null : verification.planCreatedAt.slice(0, 10));
1830
+ out.push(md ? `## ${heading}` : heading);
1831
+ out.push(t.verify.counts(n(verification.arrived), n(verification.notArrived), n(verification.cannotTell)));
1832
+ if (verification.pricesChanged) {
1833
+ out.push(t.verify.pricesChanged(verification.planPricing, verification.currentPricing));
1834
+ }
1835
+ for (const v of verification.actions) {
1836
+ out.push('');
1837
+ const [head, ...rest] = actionLine(v);
1838
+ out.push(md ? `### ${head}` : `→ ${head}`);
1839
+ for (const row of rest)
1840
+ out.push(md ? `- ${row}` : ` · ${row}`);
1841
+ }
1842
+ out.push('');
1843
+ out.push(t.verify.footer());
1844
+ return out;
1845
+ };
1846
+ await writeMarkdown(args, () => lines(true).join('\n'));
1847
+ if (boolFlag(args, 'json')) {
1848
+ console.log(JSON.stringify(verification, null, 2));
1849
+ }
1850
+ else {
1851
+ const [head, ...rest] = lines(false);
1852
+ console.log(c.bold(head));
1853
+ for (const row of rest) {
1854
+ console.log(row === '' ? '' : ` ${wrap(row, 74, ' ')}`);
1855
+ }
1856
+ }
1857
+ if (gate) {
1858
+ if (verification.gateFailures > 0) {
1859
+ console.error(c.red(t.verify.gateFailed(n(verification.gateFailures), n(verification.actions.length))));
1860
+ process.exitCode = 1;
1861
+ }
1862
+ else {
1863
+ console.log(c.green(t.verify.gateOk()));
1864
+ }
1865
+ }
1866
+ }
1647
1867
  /**
1648
1868
  * `trazum plan <log>` — not a list of findings, a ranked plan of what to do.
1649
1869
  *
@@ -4935,6 +5155,12 @@ async function main() {
4935
5155
  case 'plan':
4936
5156
  await commandPlan(args, pricing, t);
4937
5157
  break;
5158
+ case 'verify':
5159
+ await commandVerify(args, pricing, t);
5160
+ break;
5161
+ case 'history':
5162
+ await commandHistory(args, t);
5163
+ break;
4938
5164
  case 'route':
4939
5165
  await commandRoute(args, pricing, t);
4940
5166
  break;