@trazum/cli 1.50.1 → 1.50.3

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
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
2
3
  import { open, readdir, readFile, stat, writeFile } from 'node:fs/promises';
3
- import { join, resolve as resolvePath } from 'node:path';
4
+ import { dirname, join, resolve as resolvePath } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
4
6
  import { gunzipSync } from 'node:zlib';
5
- import { applyRewrites, BASELINE_FILENAME, BASELINE_VERSION, breaches, cacheableMinimum, analyzeCachePrefix, billLevers, bucketedCacheEconomics, bucketedProfile, buildHistory, buildPlan, connectorFor, CONNECTORS, normalizeAnthropicUsage, normalizeOpenAIUsage, bucketsFromRecords, evaluateWatch, firedKey, pruneRecords, recordsFromBuckets, storeInventory, storedReportFrom, verifyPlan, cacheEconomics, cacheHitRate, contextPressure, comparePrompts, compareToBaseline, computeSavings, countTokensAnthropic, DEFAULT_USAGE, budgetPositions, conform, detectFromSource, matchLocale, parsePlanDocument, waiverDay, waiverHistory, proposeInit, MIN_RATE_DAYS, parseConfig, 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';
7
+ import { applyRewrites, BASELINE_FILENAME, BASELINE_VERSION, breaches, cacheableMinimum, analyzeCachePrefix, billLevers, bucketedCacheEconomics, bucketedProfile, buildHistory, buildPlan, connectorFor, CONNECTORS, normalizeAnthropicUsage, normalizeOpenAIUsage, bucketsFromRecords, evaluateWatch, firedKey, pruneRecords, recordsFromBuckets, storeInventory, storedReportFrom, verifyPlan, cacheEconomics, cacheHitRate, contextPressure, comparePrompts, compareToBaseline, computeSavings, countTokensAnthropic, DEFAULT_USAGE, budgetPositions, conform, FAILURE_POLICIES, detectFromSource, matchLocale, parsePlanDocument, waiverDay, waiverHistory, proposeInit, MIN_RATE_DAYS, parseConfig, 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
8
  import { cacheDir, cacheStats, cachingProvider, clearCache } from './suggest-cache.js';
7
9
  import { dayOf, formatGap, median, spanDays } from './time.js';
8
10
  // Everything that reads the filesystem, on its own entry point so the web
@@ -13,6 +15,7 @@ import { fetchProviderUsage, findCredential } from './connect.js';
13
15
  import { STORE_DIR, appendRecords, readStore, rewriteStore } from './store-fs.js';
14
16
  import { WAIVER_LOG, appendWaiverUse, readWaiverLog } from './waiver-log.js';
15
17
  import { DEFAULT_PORT, buildServer, listen } from './serve.js';
18
+ import { DEFAULT_GATEWAY_PORT, UPSTREAMS, buildGateway, listenGateway, } from './gateway-server.js';
16
19
  import { WATCH_STATE_VERSION, checkWebhook, postWebhook, readWatchState, writeWatchState, } from './watch-run.js';
17
20
  import { LOCALE_ENV_VARS, detectLocale, getCliMessages } from './i18n/index.js';
18
21
  import { MAX_SUMMARY_CHARS, fitWithin, renderBlameMarkdown, renderCheckMarkdown, renderDiffMarkdown, renderRankMarkdown, renderProfileMarkdown, } from './markdown.js';
@@ -31,6 +34,7 @@ const c = {
31
34
  const VALUE_FLAGS = new Set([
32
35
  'against',
33
36
  'contract',
37
+ 'on-cannot-tell',
34
38
  'from-log',
35
39
  'min-usd',
36
40
  'payload',
@@ -287,7 +291,7 @@ function disabledRules(args, config) {
287
291
  * a threshold is set — `--max-growh 5` would have been ignored and the build
288
292
  * gone green. Silence is the wrong answer for a typo.
289
293
  */
290
- const GLOBAL_FLAGS = ['help', 'h', 'locale', 'json', 'config', 'pricing', 'pricing-live'];
294
+ const GLOBAL_FLAGS = ['help', 'h', 'version', 'v', 'locale', 'json', 'config', 'pricing', 'pricing-live'];
291
295
  const COMMAND_FLAGS = {
292
296
  optimize: [
293
297
  'level', 'model', 'calls', 'output-tokens', 'cache-hit-rate', 'batch',
@@ -313,6 +317,8 @@ const COMMAND_FLAGS = {
313
317
  rank: ['level', 'model', 'calls', 'output-tokens', 'batch', 'disable', 'prompt', 'markdown-out'],
314
318
  init: ['dry-run', 'yes', 'json', 'pricing', 'pricing-live'],
315
319
  conform: ['contract', 'json'],
320
+ feedback: [],
321
+ gateway: ['on-cannot-tell', 'port', 'socket', 'pricing', 'pricing-live'],
316
322
  where: [],
317
323
  rules: [],
318
324
  blame: ['limit', 'model', 'calls', 'output-tokens', 'batch', 'prompt', 'markdown-out'],
@@ -1039,6 +1045,43 @@ const INIT_LOG_CANDIDATES = [
1039
1045
  * answers for a file somebody named while this answers for a repository
1040
1046
  * nobody has vouched for.
1041
1047
  */
1048
+ /**
1049
+ * Where feedback goes. Compiled in, never configurable.
1050
+ *
1051
+ * A flag or a config key naming this host would let a fork — or anything that
1052
+ * had rewritten a config on disk — point somebody's bug report, and the
1053
+ * prefilled body with it, at a machine they did not choose. It is one string
1054
+ * and it stays one string.
1055
+ */
1056
+ /**
1057
+ * Which Trazum this is.
1058
+ *
1059
+ * Read from the manifest beside the built entry point rather than baked in by
1060
+ * a generator, so it cannot drift from what npm installed — the one number a
1061
+ * bug report is useless without is the one that must not be a copy.
1062
+ *
1063
+ * `readFileSync` at module load, deliberately: every other read in this file
1064
+ * is async and inside a command, but a version has to be available to
1065
+ * `--version` before any command is chosen, and one small synchronous read at
1066
+ * startup is cheaper than making the whole entry point await.
1067
+ *
1068
+ * A failure falls back to `unknown` rather than throwing. A tool that will not
1069
+ * start because it cannot find its own manifest is worse than one that admits
1070
+ * it does not know — and `unknown` in a bug report is itself a useful fact
1071
+ * about how somebody installed it.
1072
+ */
1073
+ const VERSION = (() => {
1074
+ try {
1075
+ const here = dirname(fileURLToPath(import.meta.url));
1076
+ const manifest = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'));
1077
+ const found = manifest.version;
1078
+ return typeof found === 'string' ? found : 'unknown';
1079
+ }
1080
+ catch {
1081
+ return 'unknown';
1082
+ }
1083
+ })();
1084
+ const FEEDBACK_REPO = 'https://github.com/Davmunrey/Trazum';
1042
1085
  /** Problems listed before the rest are counted. A wall of them helps nobody. */
1043
1086
  const MAX_CONFORM_PROBLEMS = 20;
1044
1087
  /** The contracts `--contract` accepts, so a typo is refused with the list. */
@@ -1467,6 +1510,161 @@ async function commandConform(args, t) {
1467
1510
  }
1468
1511
  console.log();
1469
1512
  }
1513
+ /**
1514
+ * `trazum feedback` — where to say it, and what to say.
1515
+ *
1516
+ * **This command sends nothing.** Trazum has no telemetry: the CLI makes no
1517
+ * network call it was not explicitly asked to make, and there is no ping, no
1518
+ * install hook and no anonymous counter anywhere in it. That is not an
1519
+ * omission somebody has been meaning to fix — a tool whose entire argument is
1520
+ * that it reads your bill without uploading it cannot also be quietly
1521
+ * reporting on you, and the security suite fails the build if this command
1522
+ * ever reaches the network.
1523
+ *
1524
+ * So the loop is closed the only honest way: the person decides to send
1525
+ * something, and this makes that as cheap as possible. It prints the four
1526
+ * places worth writing to, and a **prefilled link** carrying the facts a
1527
+ * maintainer always has to ask for — version, runtime, platform — printed in
1528
+ * full first, so nothing travels that the sender has not read.
1529
+ *
1530
+ * Nothing about *their work* is in it. Not the config, not a prompt, not a
1531
+ * label, not a figure. Those are the things a bug report needs and the things
1532
+ * only the reporter can decide to share, and a command that helpfully attached
1533
+ * them would be the leak this product exists not to be.
1534
+ */
1535
+ function commandFeedback(t) {
1536
+ const version = VERSION;
1537
+ /**
1538
+ * Facts about the machine, and nothing about the person.
1539
+ *
1540
+ * `process.platform` and the Node version are what every "cannot reproduce"
1541
+ * thread eventually asks for. The locale is here because Trazum ships two
1542
+ * languages and a report reading wrong in one of them is a real bug class.
1543
+ */
1544
+ const environment = [
1545
+ `Trazum ${version}`,
1546
+ `Node ${process.version}`,
1547
+ `${process.platform} ${process.arch}`,
1548
+ `locale ${t.locale}`,
1549
+ ];
1550
+ const body = [
1551
+ '<!-- What happened, and what you expected instead. -->',
1552
+ '',
1553
+ '',
1554
+ '---',
1555
+ ...environment.map((line) => `- ${line}`),
1556
+ ].join('\n');
1557
+ const url = `${FEEDBACK_REPO}/issues/new?body=${encodeURIComponent(body)}`;
1558
+ console.log();
1559
+ console.log(c.bold(t.feedback.heading()));
1560
+ console.log(` ${c.dim(wrap(t.feedback.sendsNothing(), 74, ' '))}`);
1561
+ console.log();
1562
+ console.log(c.bold(t.feedback.whereHeading()));
1563
+ console.log(` ${t.feedback.wrongOptimisation()}`);
1564
+ console.log(` ${c.dim(`${FEEDBACK_REPO}/issues/new?template=wrong_optimisation.yml`)}`);
1565
+ console.log(` ${t.feedback.bug()}`);
1566
+ console.log(` ${c.dim(`${FEEDBACK_REPO}/issues/new?template=bug_report.yml`)}`);
1567
+ console.log(` ${t.feedback.question()}`);
1568
+ console.log(` ${c.dim(`${FEEDBACK_REPO}/discussions`)}`);
1569
+ console.log(` ${t.feedback.security()}`);
1570
+ console.log(` ${c.dim(`${FEEDBACK_REPO}/security/advisories/new`)}`);
1571
+ console.log();
1572
+ console.log(c.bold(t.feedback.environmentHeading()));
1573
+ for (const line of environment)
1574
+ console.log(` ${line}`);
1575
+ console.log(` ${c.dim(wrap(t.feedback.environmentOnly(), 74, ' '))}`);
1576
+ console.log();
1577
+ console.log(c.bold(t.feedback.linkHeading()));
1578
+ console.log(` ${url}`);
1579
+ console.log();
1580
+ }
1581
+ /**
1582
+ * `trazum gateway <provider>` — in the path, and refusing rather than advising.
1583
+ *
1584
+ * The last thing this product could not do. `serve` answers a question an
1585
+ * implementation may ignore; a connector reports the runaway after it ran.
1586
+ * Standing between the caller and the provider fixes both — usage is measured
1587
+ * from the provider's own response as it comes back, and a refusal is a
1588
+ * refusal.
1589
+ *
1590
+ * **The failure policy is required.** `--on-cannot-tell fail-open` keeps the
1591
+ * product working and lets the bill run; `fail-closed` stops the bill and takes
1592
+ * the product down with it. Both are defensible and there is deliberately no
1593
+ * default: a proxy that picks silently has made the most consequential decision
1594
+ * in somebody's architecture on their behalf, at install time, without saying
1595
+ * so.
1596
+ *
1597
+ * **Substitution is off unless it is written down.** `spend.substitute` in the
1598
+ * config, with the operator's own reason, and every substituted call is marked
1599
+ * so no later report treats it as the call the caller made.
1600
+ */
1601
+ async function commandGateway(args, config, configDir, pricing, t) {
1602
+ const provider = args.positional[0];
1603
+ if (provider === undefined || UPSTREAMS[provider] === undefined) {
1604
+ throw new Error(t.gateway.badProvider(provider ?? '', Object.keys(UPSTREAMS).join(', ')));
1605
+ }
1606
+ /**
1607
+ * No default, and the error says why rather than just what.
1608
+ *
1609
+ * The one flag in this product that refuses to guess on the reader's behalf,
1610
+ * because the two answers differ in which failure they accept and nobody but
1611
+ * the operator knows which their product can survive.
1612
+ */
1613
+ const policyFlag = stringFlag(args, 'on-cannot-tell');
1614
+ if (policyFlag === undefined || !FAILURE_POLICIES.includes(policyFlag)) {
1615
+ throw new Error(t.gateway.needsPolicy(FAILURE_POLICIES.join(', ')));
1616
+ }
1617
+ const { resolved } = await readStore(configDir);
1618
+ const budget = budgetPositions(resolved.records, config.spend, { catalogue: pricing });
1619
+ const position = budget.positions[0] ?? null;
1620
+ /**
1621
+ * Read once at start, like `serve`'s.
1622
+ *
1623
+ * A file read in the request path would put Trazum's own latency between a
1624
+ * caller and their provider on every call, which is a cost this product
1625
+ * would otherwise be reporting on somebody else. The staleness is real, so a
1626
+ * refusal carries `asOfMs` and says what it rested on.
1627
+ */
1628
+ const standing = position === null || position.coverage === 'none'
1629
+ ? null
1630
+ : {
1631
+ limitUsd: position.limitUsd,
1632
+ consumedUsd: position.consumedUsd,
1633
+ provenance: 'measured',
1634
+ asOfMs: Date.now(),
1635
+ };
1636
+ const measured = { calls: 0, usd: 0 };
1637
+ const server = buildGateway({
1638
+ provider,
1639
+ catalogue: pricing,
1640
+ policy: {
1641
+ onCannotTell: policyFlag,
1642
+ ...(config.spend?.substitute === undefined ? {} : { substitute: config.spend.substitute }),
1643
+ },
1644
+ standing: () => standing,
1645
+ record: (call) => {
1646
+ measured.calls += 1;
1647
+ console.error(c.dim(t.gateway.measured(call.model, call.label, call.inputTokens, call.outputTokens, call.substituted)));
1648
+ },
1649
+ note: (line) => {
1650
+ console.error(c.yellow(` ${line}`));
1651
+ },
1652
+ });
1653
+ const socket = stringFlag(args, 'socket');
1654
+ const portRaw = stringFlag(args, 'port');
1655
+ const port = portRaw === undefined ? DEFAULT_GATEWAY_PORT : Number(portRaw);
1656
+ if (socket === undefined && (!Number.isInteger(port) || port < 0 || port > 65_535)) {
1657
+ throw new Error(t.serve.badPort(String(portRaw)));
1658
+ }
1659
+ const where = await listenGateway(server, socket !== undefined ? { socket } : { port });
1660
+ console.log(c.bold(t.gateway.listening(where, provider)));
1661
+ console.log(` ${c.dim(wrap(t.gateway.pointYourSdk(where), 74, ' '))}`);
1662
+ console.log(` ${c.dim(wrap(t.gateway.credential(), 74, ' '))}`);
1663
+ console.log(` ${c.dim(wrap(t.gateway.neverSubstitutes(), 74, ' '))}`);
1664
+ console.log(` ${c.dim(wrap(standing === null ? t.gateway.noStanding() : t.gateway.standing(formatUsd(standing.consumedUsd), formatUsd(standing.limitUsd)), 74, ' '))}`);
1665
+ console.log(` ${c.dim(wrap(t.gateway.policy(policyFlag), 74, ' '))}`);
1666
+ console.log();
1667
+ }
1470
1668
  function commandModels(t, pricing) {
1471
1669
  const n = (value) => value.toLocaleString(t.numberLocale);
1472
1670
  const col = t.models.columns;
@@ -2653,7 +2851,7 @@ async function commandConnect(args, pricing, t) {
2653
2851
  * same action planned twice — and no series, however long, becomes a
2654
2852
  * forecast.
2655
2853
  */
2656
- async function commandHistory(args, config, pricing, t) {
2854
+ async function commandHistory(args, config, configDir, pricing, t) {
2657
2855
  /**
2658
2856
  * `--store` builds the series from measured spend already on disk.
2659
2857
  *
@@ -2765,7 +2963,7 @@ async function commandHistory(args, config, pricing, t) {
2765
2963
  * the waiver record belongs to the repository whose gates fired, and the
2766
2964
  * stored reports may have come from anywhere.
2767
2965
  */
2768
- const waivers = await readWaiverLog('.');
2966
+ const waivers = await readWaiverLog(configDir);
2769
2967
  const waiverReport = waiverHistory(waivers.uses, config.waive ?? []);
2770
2968
  const stamped = { ...history, unrecognizedFiles: unrecognized, waivers: waiverReport };
2771
2969
  const n = (value) => value.toLocaleString(t.numberLocale);
@@ -3117,7 +3315,7 @@ async function commandPlan(args, pricing, t) {
3117
3315
  if (outPath !== undefined)
3118
3316
  console.log(c.dim(wrap(t.plan.wrote(outPath), 74, '')));
3119
3317
  }
3120
- async function commandProfile(args, config, pricing, t) {
3318
+ async function commandProfile(args, config, configDir, pricing, t) {
3121
3319
  const path = args.positional[0];
3122
3320
  if (path === undefined) {
3123
3321
  console.log();
@@ -3876,7 +4074,7 @@ async function commandProfile(args, config, pricing, t) {
3876
4074
  if (waiverUses.length === 0)
3877
4075
  return;
3878
4076
  for (const use of waiverUses) {
3879
- const failed = await appendWaiverUse('.', use);
4077
+ const failed = await appendWaiverUse(configDir, use);
3880
4078
  if (failed !== null) {
3881
4079
  console.error(c.dim(t.profile.waiveNotRecorded(WAIVER_LOG, failed)));
3882
4080
  return;
@@ -6265,6 +6463,19 @@ async function main() {
6265
6463
  console.log(t.cache.cleared(removed, before.bytes, dir));
6266
6464
  return;
6267
6465
  }
6466
+ /**
6467
+ * Before the help branch, and before the config loads.
6468
+ *
6469
+ * `trazum --version` on its own is how somebody answers "which one is
6470
+ * installed", and it has to work when the config is broken — that is
6471
+ * precisely the moment they are being asked. Placed above `!args.command`
6472
+ * for the same reason `--clear-suggestion-cache` is: with nothing else on
6473
+ * the line, the help branch would have swallowed it.
6474
+ */
6475
+ if (boolFlag(args, 'version') || boolFlag(args, 'v')) {
6476
+ console.log(VERSION);
6477
+ return;
6478
+ }
6268
6479
  if (boolFlag(args, 'help') || boolFlag(args, 'h') || !args.command) {
6269
6480
  console.log(t.help({
6270
6481
  model: DEFAULT_USAGE.model,
@@ -6310,6 +6521,22 @@ async function main() {
6310
6521
  };
6311
6522
  }
6312
6523
  const { config } = loaded;
6524
+ /**
6525
+ * Where the waiver record lives: **beside the config that declared it**.
6526
+ *
6527
+ * It used to be the process's working directory, which is a different place
6528
+ * whenever somebody runs `trazum profile ../logs/x.jsonl --config ../repo/
6529
+ * trazum.config.json` — and that is not hypothetical. This repository's own
6530
+ * test suite did exactly that from `packages/cli`, so sixty records of a
6531
+ * fixture's decisions accumulated in a package directory and one of them was
6532
+ * committed to `main`, where it sat for two releases.
6533
+ *
6534
+ * A waiver is a decision a *repository* made. The record of using it belongs
6535
+ * with the file that made it, not with wherever the terminal happened to be.
6536
+ * No config means no waivers, so there is nothing to write and `.` is never
6537
+ * reached.
6538
+ */
6539
+ const configDir = loaded.path === null ? '.' : dirname(loaded.path);
6313
6540
  const pricing = await pricingFor(args, loaded, t);
6314
6541
  // The config only gets to choose the locale when nothing more explicit did.
6315
6542
  if (config.locale && !stringFlag(args, 'locale')) {
@@ -6327,7 +6554,7 @@ async function main() {
6327
6554
  await commandBaseline(args, config, pricing, t, locale);
6328
6555
  break;
6329
6556
  case 'profile':
6330
- await commandProfile(args, config, pricing, t);
6557
+ await commandProfile(args, config, configDir, pricing, t);
6331
6558
  break;
6332
6559
  case 'plan':
6333
6560
  await commandPlan(args, pricing, t);
@@ -6336,7 +6563,7 @@ async function main() {
6336
6563
  await commandVerify(args, pricing, t);
6337
6564
  break;
6338
6565
  case 'history':
6339
- await commandHistory(args, config, pricing, t);
6566
+ await commandHistory(args, config, configDir, pricing, t);
6340
6567
  break;
6341
6568
  case 'connect':
6342
6569
  await commandConnect(args, pricing, t);
@@ -6365,6 +6592,12 @@ async function main() {
6365
6592
  case 'models':
6366
6593
  commandModels(t, pricing);
6367
6594
  break;
6595
+ case 'gateway':
6596
+ await commandGateway(args, config, configDir, pricing, t);
6597
+ break;
6598
+ case 'feedback':
6599
+ commandFeedback(t);
6600
+ break;
6368
6601
  case 'conform':
6369
6602
  await commandConform(args, t);
6370
6603
  break;