@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/src/index.ts CHANGED
@@ -1,6 +1,8 @@
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
7
 
6
8
  import {
@@ -37,6 +39,7 @@ import {
37
39
  DEFAULT_USAGE,
38
40
  budgetPositions,
39
41
  conform,
42
+ FAILURE_POLICIES,
40
43
  detectFromSource,
41
44
  matchLocale,
42
45
  parsePlanDocument,
@@ -131,6 +134,8 @@ import type {
131
134
  import type {
132
135
  BudgetReport,
133
136
  ContractName,
137
+ FailurePolicy,
138
+ GatewayStanding,
134
139
  UsageProfileReport,
135
140
  WaiverUse,
136
141
  InitDecline,
@@ -177,6 +182,12 @@ import { fetchProviderUsage, findCredential } from './connect.js';
177
182
  import { STORE_DIR, appendRecords, readStore, rewriteStore } from './store-fs.js';
178
183
  import { WAIVER_LOG, appendWaiverUse, readWaiverLog } from './waiver-log.js';
179
184
  import { DEFAULT_PORT, buildServer, listen } from './serve.js';
185
+ import {
186
+ DEFAULT_GATEWAY_PORT,
187
+ UPSTREAMS,
188
+ buildGateway,
189
+ listenGateway,
190
+ } from './gateway-server.js';
180
191
  import {
181
192
  WATCH_STATE_VERSION,
182
193
  checkWebhook,
@@ -231,6 +242,7 @@ interface Args {
231
242
  const VALUE_FLAGS = new Set([
232
243
  'against',
233
244
  'contract',
245
+ 'on-cannot-tell',
234
246
  'from-log',
235
247
  'min-usd',
236
248
  'payload',
@@ -528,7 +540,7 @@ function disabledRules(args: Args, config: TrazumConfig): RuleId[] | undefined {
528
540
  * a threshold is set — `--max-growh 5` would have been ignored and the build
529
541
  * gone green. Silence is the wrong answer for a typo.
530
542
  */
531
- const GLOBAL_FLAGS = ['help', 'h', 'locale', 'json', 'config', 'pricing', 'pricing-live'];
543
+ const GLOBAL_FLAGS = ['help', 'h', 'version', 'v', 'locale', 'json', 'config', 'pricing', 'pricing-live'];
532
544
  const COMMAND_FLAGS: Record<string, string[]> = {
533
545
  optimize: [
534
546
  'level', 'model', 'calls', 'output-tokens', 'cache-hit-rate', 'batch',
@@ -554,6 +566,8 @@ const COMMAND_FLAGS: Record<string, string[]> = {
554
566
  rank: ['level', 'model', 'calls', 'output-tokens', 'batch', 'disable', 'prompt', 'markdown-out'],
555
567
  init: ['dry-run', 'yes', 'json', 'pricing', 'pricing-live'],
556
568
  conform: ['contract', 'json'],
569
+ feedback: [],
570
+ gateway: ['on-cannot-tell', 'port', 'socket', 'pricing', 'pricing-live'],
557
571
  where: [],
558
572
  rules: [],
559
573
  blame: ['limit', 'model', 'calls', 'output-tokens', 'batch', 'prompt', 'markdown-out'],
@@ -1476,6 +1490,44 @@ const INIT_LOG_CANDIDATES = [
1476
1490
  * nobody has vouched for.
1477
1491
  */
1478
1492
 
1493
+ /**
1494
+ * Where feedback goes. Compiled in, never configurable.
1495
+ *
1496
+ * A flag or a config key naming this host would let a fork — or anything that
1497
+ * had rewritten a config on disk — point somebody's bug report, and the
1498
+ * prefilled body with it, at a machine they did not choose. It is one string
1499
+ * and it stays one string.
1500
+ */
1501
+ /**
1502
+ * Which Trazum this is.
1503
+ *
1504
+ * Read from the manifest beside the built entry point rather than baked in by
1505
+ * a generator, so it cannot drift from what npm installed — the one number a
1506
+ * bug report is useless without is the one that must not be a copy.
1507
+ *
1508
+ * `readFileSync` at module load, deliberately: every other read in this file
1509
+ * is async and inside a command, but a version has to be available to
1510
+ * `--version` before any command is chosen, and one small synchronous read at
1511
+ * startup is cheaper than making the whole entry point await.
1512
+ *
1513
+ * A failure falls back to `unknown` rather than throwing. A tool that will not
1514
+ * start because it cannot find its own manifest is worse than one that admits
1515
+ * it does not know — and `unknown` in a bug report is itself a useful fact
1516
+ * about how somebody installed it.
1517
+ */
1518
+ const VERSION: string = (() => {
1519
+ try {
1520
+ const here = dirname(fileURLToPath(import.meta.url));
1521
+ const manifest: unknown = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'));
1522
+ const found = (manifest as { version?: unknown }).version;
1523
+ return typeof found === 'string' ? found : 'unknown';
1524
+ } catch {
1525
+ return 'unknown';
1526
+ }
1527
+ })();
1528
+
1529
+ const FEEDBACK_REPO = 'https://github.com/Davmunrey/Trazum';
1530
+
1479
1531
  /** Problems listed before the rest are counted. A wall of them helps nobody. */
1480
1532
  const MAX_CONFORM_PROBLEMS = 20;
1481
1533
 
@@ -1949,6 +2001,193 @@ async function commandConform(args: Args, t: CliMessages): Promise<void> {
1949
2001
  console.log();
1950
2002
  }
1951
2003
 
2004
+ /**
2005
+ * `trazum feedback` — where to say it, and what to say.
2006
+ *
2007
+ * **This command sends nothing.** Trazum has no telemetry: the CLI makes no
2008
+ * network call it was not explicitly asked to make, and there is no ping, no
2009
+ * install hook and no anonymous counter anywhere in it. That is not an
2010
+ * omission somebody has been meaning to fix — a tool whose entire argument is
2011
+ * that it reads your bill without uploading it cannot also be quietly
2012
+ * reporting on you, and the security suite fails the build if this command
2013
+ * ever reaches the network.
2014
+ *
2015
+ * So the loop is closed the only honest way: the person decides to send
2016
+ * something, and this makes that as cheap as possible. It prints the four
2017
+ * places worth writing to, and a **prefilled link** carrying the facts a
2018
+ * maintainer always has to ask for — version, runtime, platform — printed in
2019
+ * full first, so nothing travels that the sender has not read.
2020
+ *
2021
+ * Nothing about *their work* is in it. Not the config, not a prompt, not a
2022
+ * label, not a figure. Those are the things a bug report needs and the things
2023
+ * only the reporter can decide to share, and a command that helpfully attached
2024
+ * them would be the leak this product exists not to be.
2025
+ */
2026
+ function commandFeedback(t: CliMessages): void {
2027
+ const version = VERSION;
2028
+ /**
2029
+ * Facts about the machine, and nothing about the person.
2030
+ *
2031
+ * `process.platform` and the Node version are what every "cannot reproduce"
2032
+ * thread eventually asks for. The locale is here because Trazum ships two
2033
+ * languages and a report reading wrong in one of them is a real bug class.
2034
+ */
2035
+ const environment = [
2036
+ `Trazum ${version}`,
2037
+ `Node ${process.version}`,
2038
+ `${process.platform} ${process.arch}`,
2039
+ `locale ${t.locale}`,
2040
+ ];
2041
+
2042
+ const body = [
2043
+ '<!-- What happened, and what you expected instead. -->',
2044
+ '',
2045
+ '',
2046
+ '---',
2047
+ ...environment.map((line) => `- ${line}`),
2048
+ ].join('\n');
2049
+ const url =
2050
+ `${FEEDBACK_REPO}/issues/new?body=${encodeURIComponent(body)}`;
2051
+
2052
+ console.log();
2053
+ console.log(c.bold(t.feedback.heading()));
2054
+ console.log(` ${c.dim(wrap(t.feedback.sendsNothing(), 74, ' '))}`);
2055
+ console.log();
2056
+
2057
+ console.log(c.bold(t.feedback.whereHeading()));
2058
+ console.log(` ${t.feedback.wrongOptimisation()}`);
2059
+ console.log(` ${c.dim(`${FEEDBACK_REPO}/issues/new?template=wrong_optimisation.yml`)}`);
2060
+ console.log(` ${t.feedback.bug()}`);
2061
+ console.log(` ${c.dim(`${FEEDBACK_REPO}/issues/new?template=bug_report.yml`)}`);
2062
+ console.log(` ${t.feedback.question()}`);
2063
+ console.log(` ${c.dim(`${FEEDBACK_REPO}/discussions`)}`);
2064
+ console.log(` ${t.feedback.security()}`);
2065
+ console.log(` ${c.dim(`${FEEDBACK_REPO}/security/advisories/new`)}`);
2066
+ console.log();
2067
+
2068
+ console.log(c.bold(t.feedback.environmentHeading()));
2069
+ for (const line of environment) console.log(` ${line}`);
2070
+ console.log(` ${c.dim(wrap(t.feedback.environmentOnly(), 74, ' '))}`);
2071
+ console.log();
2072
+
2073
+ console.log(c.bold(t.feedback.linkHeading()));
2074
+ console.log(` ${url}`);
2075
+ console.log();
2076
+ }
2077
+
2078
+ /**
2079
+ * `trazum gateway <provider>` — in the path, and refusing rather than advising.
2080
+ *
2081
+ * The last thing this product could not do. `serve` answers a question an
2082
+ * implementation may ignore; a connector reports the runaway after it ran.
2083
+ * Standing between the caller and the provider fixes both — usage is measured
2084
+ * from the provider's own response as it comes back, and a refusal is a
2085
+ * refusal.
2086
+ *
2087
+ * **The failure policy is required.** `--on-cannot-tell fail-open` keeps the
2088
+ * product working and lets the bill run; `fail-closed` stops the bill and takes
2089
+ * the product down with it. Both are defensible and there is deliberately no
2090
+ * default: a proxy that picks silently has made the most consequential decision
2091
+ * in somebody's architecture on their behalf, at install time, without saying
2092
+ * so.
2093
+ *
2094
+ * **Substitution is off unless it is written down.** `spend.substitute` in the
2095
+ * config, with the operator's own reason, and every substituted call is marked
2096
+ * so no later report treats it as the call the caller made.
2097
+ */
2098
+ async function commandGateway(
2099
+ args: Args,
2100
+ config: TrazumConfig,
2101
+ configDir: string,
2102
+ pricing: PricingCatalogue,
2103
+ t: CliMessages,
2104
+ ): Promise<void> {
2105
+ const provider = args.positional[0];
2106
+ if (provider === undefined || UPSTREAMS[provider] === undefined) {
2107
+ throw new Error(t.gateway.badProvider(provider ?? '', Object.keys(UPSTREAMS).join(', ')));
2108
+ }
2109
+
2110
+ /**
2111
+ * No default, and the error says why rather than just what.
2112
+ *
2113
+ * The one flag in this product that refuses to guess on the reader's behalf,
2114
+ * because the two answers differ in which failure they accept and nobody but
2115
+ * the operator knows which their product can survive.
2116
+ */
2117
+ const policyFlag = stringFlag(args, 'on-cannot-tell');
2118
+ if (policyFlag === undefined || !FAILURE_POLICIES.includes(policyFlag as FailurePolicy)) {
2119
+ throw new Error(t.gateway.needsPolicy(FAILURE_POLICIES.join(', ')));
2120
+ }
2121
+
2122
+ const { resolved } = await readStore(configDir);
2123
+ const budget = budgetPositions(resolved.records, config.spend, { catalogue: pricing });
2124
+ const position = budget.positions[0] ?? null;
2125
+
2126
+ /**
2127
+ * Read once at start, like `serve`'s.
2128
+ *
2129
+ * A file read in the request path would put Trazum's own latency between a
2130
+ * caller and their provider on every call, which is a cost this product
2131
+ * would otherwise be reporting on somebody else. The staleness is real, so a
2132
+ * refusal carries `asOfMs` and says what it rested on.
2133
+ */
2134
+ const standing: GatewayStanding | null =
2135
+ position === null || position.coverage === 'none'
2136
+ ? null
2137
+ : {
2138
+ limitUsd: position.limitUsd,
2139
+ consumedUsd: position.consumedUsd,
2140
+ provenance: 'measured',
2141
+ asOfMs: Date.now(),
2142
+ };
2143
+
2144
+ const measured: { calls: number; usd: number } = { calls: 0, usd: 0 };
2145
+ const server = buildGateway({
2146
+ provider,
2147
+ catalogue: pricing,
2148
+ policy: {
2149
+ onCannotTell: policyFlag as FailurePolicy,
2150
+ ...(config.spend?.substitute === undefined ? {} : { substitute: config.spend.substitute }),
2151
+ },
2152
+ standing: () => standing,
2153
+ record: (call) => {
2154
+ measured.calls += 1;
2155
+ console.error(
2156
+ c.dim(
2157
+ t.gateway.measured(
2158
+ call.model,
2159
+ call.label,
2160
+ call.inputTokens,
2161
+ call.outputTokens,
2162
+ call.substituted,
2163
+ ),
2164
+ ),
2165
+ );
2166
+ },
2167
+ note: (line) => {
2168
+ console.error(c.yellow(` ${line}`));
2169
+ },
2170
+ });
2171
+
2172
+ const socket = stringFlag(args, 'socket');
2173
+ const portRaw = stringFlag(args, 'port');
2174
+ const port = portRaw === undefined ? DEFAULT_GATEWAY_PORT : Number(portRaw);
2175
+ if (socket === undefined && (!Number.isInteger(port) || port < 0 || port > 65_535)) {
2176
+ throw new Error(t.serve.badPort(String(portRaw)));
2177
+ }
2178
+
2179
+ const where = await listenGateway(server, socket !== undefined ? { socket } : { port });
2180
+ console.log(c.bold(t.gateway.listening(where, provider)));
2181
+ console.log(` ${c.dim(wrap(t.gateway.pointYourSdk(where), 74, ' '))}`);
2182
+ console.log(` ${c.dim(wrap(t.gateway.credential(), 74, ' '))}`);
2183
+ console.log(` ${c.dim(wrap(t.gateway.neverSubstitutes(), 74, ' '))}`);
2184
+ console.log(
2185
+ ` ${c.dim(wrap(standing === null ? t.gateway.noStanding() : t.gateway.standing(formatUsd(standing.consumedUsd), formatUsd(standing.limitUsd)), 74, ' '))}`,
2186
+ );
2187
+ console.log(` ${c.dim(wrap(t.gateway.policy(policyFlag), 74, ' '))}`);
2188
+ console.log();
2189
+ }
2190
+
1952
2191
  function commandModels(t: CliMessages, pricing: PricingCatalogue): void {
1953
2192
  const n = (value: number): string => value.toLocaleString(t.numberLocale);
1954
2193
  const col = t.models.columns;
@@ -3525,6 +3764,7 @@ async function commandConnect(
3525
3764
  async function commandHistory(
3526
3765
  args: Args,
3527
3766
  config: TrazumConfig,
3767
+ configDir: string,
3528
3768
  pricing: PricingCatalogue,
3529
3769
  t: CliMessages,
3530
3770
  ): Promise<void> {
@@ -3644,7 +3884,7 @@ async function commandHistory(
3644
3884
  * the waiver record belongs to the repository whose gates fired, and the
3645
3885
  * stored reports may have come from anywhere.
3646
3886
  */
3647
- const waivers = await readWaiverLog('.');
3887
+ const waivers = await readWaiverLog(configDir);
3648
3888
  const waiverReport = waiverHistory(waivers.uses, config.waive ?? []);
3649
3889
 
3650
3890
  const stamped = { ...history, unrecognizedFiles: unrecognized, waivers: waiverReport };
@@ -4046,7 +4286,13 @@ async function commandPlan(
4046
4286
  if (outPath !== undefined) console.log(c.dim(wrap(t.plan.wrote(outPath), 74, '')));
4047
4287
  }
4048
4288
 
4049
- async function commandProfile(args: Args, config: TrazumConfig, pricing: PricingCatalogue, t: CliMessages): Promise<void> {
4289
+ async function commandProfile(
4290
+ args: Args,
4291
+ config: TrazumConfig,
4292
+ configDir: string,
4293
+ pricing: PricingCatalogue,
4294
+ t: CliMessages,
4295
+ ): Promise<void> {
4050
4296
  const path = args.positional[0];
4051
4297
  if (path === undefined) {
4052
4298
  console.log();
@@ -4870,7 +5116,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4870
5116
  const recordWaiverUses = async (): Promise<void> => {
4871
5117
  if (waiverUses.length === 0) return;
4872
5118
  for (const use of waiverUses) {
4873
- const failed = await appendWaiverUse('.', use);
5119
+ const failed = await appendWaiverUse(configDir, use);
4874
5120
  if (failed !== null) {
4875
5121
  console.error(c.dim(t.profile.waiveNotRecorded(WAIVER_LOG, failed)));
4876
5122
  return;
@@ -7835,6 +8081,20 @@ async function main(): Promise<void> {
7835
8081
  return;
7836
8082
  }
7837
8083
 
8084
+ /**
8085
+ * Before the help branch, and before the config loads.
8086
+ *
8087
+ * `trazum --version` on its own is how somebody answers "which one is
8088
+ * installed", and it has to work when the config is broken — that is
8089
+ * precisely the moment they are being asked. Placed above `!args.command`
8090
+ * for the same reason `--clear-suggestion-cache` is: with nothing else on
8091
+ * the line, the help branch would have swallowed it.
8092
+ */
8093
+ if (boolFlag(args, 'version') || boolFlag(args, 'v')) {
8094
+ console.log(VERSION);
8095
+ return;
8096
+ }
8097
+
7838
8098
  if (boolFlag(args, 'help') || boolFlag(args, 'h') || !args.command) {
7839
8099
  console.log(
7840
8100
  t.help(
@@ -7885,6 +8145,22 @@ async function main(): Promise<void> {
7885
8145
  };
7886
8146
  }
7887
8147
  const { config } = loaded;
8148
+ /**
8149
+ * Where the waiver record lives: **beside the config that declared it**.
8150
+ *
8151
+ * It used to be the process's working directory, which is a different place
8152
+ * whenever somebody runs `trazum profile ../logs/x.jsonl --config ../repo/
8153
+ * trazum.config.json` — and that is not hypothetical. This repository's own
8154
+ * test suite did exactly that from `packages/cli`, so sixty records of a
8155
+ * fixture's decisions accumulated in a package directory and one of them was
8156
+ * committed to `main`, where it sat for two releases.
8157
+ *
8158
+ * A waiver is a decision a *repository* made. The record of using it belongs
8159
+ * with the file that made it, not with wherever the terminal happened to be.
8160
+ * No config means no waivers, so there is nothing to write and `.` is never
8161
+ * reached.
8162
+ */
8163
+ const configDir = loaded.path === null ? '.' : dirname(loaded.path);
7888
8164
  const pricing = await pricingFor(args, loaded, t);
7889
8165
 
7890
8166
  // The config only gets to choose the locale when nothing more explicit did.
@@ -7904,7 +8180,7 @@ async function main(): Promise<void> {
7904
8180
  await commandBaseline(args, config, pricing, t, locale);
7905
8181
  break;
7906
8182
  case 'profile':
7907
- await commandProfile(args, config, pricing, t);
8183
+ await commandProfile(args, config, configDir, pricing, t);
7908
8184
  break;
7909
8185
  case 'plan':
7910
8186
  await commandPlan(args, pricing, t);
@@ -7913,7 +8189,7 @@ async function main(): Promise<void> {
7913
8189
  await commandVerify(args, pricing, t);
7914
8190
  break;
7915
8191
  case 'history':
7916
- await commandHistory(args, config, pricing, t);
8192
+ await commandHistory(args, config, configDir, pricing, t);
7917
8193
  break;
7918
8194
  case 'connect':
7919
8195
  await commandConnect(args, pricing, t);
@@ -7942,6 +8218,12 @@ async function main(): Promise<void> {
7942
8218
  case 'models':
7943
8219
  commandModels(t, pricing);
7944
8220
  break;
8221
+ case 'gateway':
8222
+ await commandGateway(args, config, configDir, pricing, t);
8223
+ break;
8224
+ case 'feedback':
8225
+ commandFeedback(t);
8226
+ break;
7945
8227
  case 'conform':
7946
8228
  await commandConform(args, t);
7947
8229
  break;