@trazum/cli 1.50.2 → 1.50.4

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
@@ -39,6 +39,8 @@ import {
39
39
  DEFAULT_USAGE,
40
40
  budgetPositions,
41
41
  conform,
42
+ outcomeReport,
43
+ FAILURE_POLICIES,
42
44
  detectFromSource,
43
45
  matchLocale,
44
46
  parsePlanDocument,
@@ -133,6 +135,8 @@ import type {
133
135
  import type {
134
136
  BudgetReport,
135
137
  ContractName,
138
+ FailurePolicy,
139
+ GatewayStanding,
136
140
  UsageProfileReport,
137
141
  WaiverUse,
138
142
  InitDecline,
@@ -179,6 +183,12 @@ import { fetchProviderUsage, findCredential } from './connect.js';
179
183
  import { STORE_DIR, appendRecords, readStore, rewriteStore } from './store-fs.js';
180
184
  import { WAIVER_LOG, appendWaiverUse, readWaiverLog } from './waiver-log.js';
181
185
  import { DEFAULT_PORT, buildServer, listen } from './serve.js';
186
+ import {
187
+ DEFAULT_GATEWAY_PORT,
188
+ UPSTREAMS,
189
+ buildGateway,
190
+ listenGateway,
191
+ } from './gateway-server.js';
182
192
  import {
183
193
  WATCH_STATE_VERSION,
184
194
  checkWebhook,
@@ -233,6 +243,7 @@ interface Args {
233
243
  const VALUE_FLAGS = new Set([
234
244
  'against',
235
245
  'contract',
246
+ 'on-cannot-tell',
236
247
  'from-log',
237
248
  'min-usd',
238
249
  'payload',
@@ -557,6 +568,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
557
568
  init: ['dry-run', 'yes', 'json', 'pricing', 'pricing-live'],
558
569
  conform: ['contract', 'json'],
559
570
  feedback: [],
571
+ gateway: ['on-cannot-tell', 'port', 'socket', 'pricing', 'pricing-live'],
560
572
  where: [],
561
573
  rules: [],
562
574
  blame: ['limit', 'model', 'calls', 'output-tokens', 'batch', 'prompt', 'markdown-out'],
@@ -2064,6 +2076,119 @@ function commandFeedback(t: CliMessages): void {
2064
2076
  console.log();
2065
2077
  }
2066
2078
 
2079
+ /**
2080
+ * `trazum gateway <provider>` — in the path, and refusing rather than advising.
2081
+ *
2082
+ * The last thing this product could not do. `serve` answers a question an
2083
+ * implementation may ignore; a connector reports the runaway after it ran.
2084
+ * Standing between the caller and the provider fixes both — usage is measured
2085
+ * from the provider's own response as it comes back, and a refusal is a
2086
+ * refusal.
2087
+ *
2088
+ * **The failure policy is required.** `--on-cannot-tell fail-open` keeps the
2089
+ * product working and lets the bill run; `fail-closed` stops the bill and takes
2090
+ * the product down with it. Both are defensible and there is deliberately no
2091
+ * default: a proxy that picks silently has made the most consequential decision
2092
+ * in somebody's architecture on their behalf, at install time, without saying
2093
+ * so.
2094
+ *
2095
+ * **Substitution is off unless it is written down.** `spend.substitute` in the
2096
+ * config, with the operator's own reason, and every substituted call is marked
2097
+ * so no later report treats it as the call the caller made.
2098
+ */
2099
+ async function commandGateway(
2100
+ args: Args,
2101
+ config: TrazumConfig,
2102
+ configDir: string,
2103
+ pricing: PricingCatalogue,
2104
+ t: CliMessages,
2105
+ ): Promise<void> {
2106
+ const provider = args.positional[0];
2107
+ if (provider === undefined || UPSTREAMS[provider] === undefined) {
2108
+ throw new Error(t.gateway.badProvider(provider ?? '', Object.keys(UPSTREAMS).join(', ')));
2109
+ }
2110
+
2111
+ /**
2112
+ * No default, and the error says why rather than just what.
2113
+ *
2114
+ * The one flag in this product that refuses to guess on the reader's behalf,
2115
+ * because the two answers differ in which failure they accept and nobody but
2116
+ * the operator knows which their product can survive.
2117
+ */
2118
+ const policyFlag = stringFlag(args, 'on-cannot-tell');
2119
+ if (policyFlag === undefined || !FAILURE_POLICIES.includes(policyFlag as FailurePolicy)) {
2120
+ throw new Error(t.gateway.needsPolicy(FAILURE_POLICIES.join(', ')));
2121
+ }
2122
+
2123
+ const { resolved } = await readStore(configDir);
2124
+ const budget = budgetPositions(resolved.records, config.spend, { catalogue: pricing });
2125
+ const position = budget.positions[0] ?? null;
2126
+
2127
+ /**
2128
+ * Read once at start, like `serve`'s.
2129
+ *
2130
+ * A file read in the request path would put Trazum's own latency between a
2131
+ * caller and their provider on every call, which is a cost this product
2132
+ * would otherwise be reporting on somebody else. The staleness is real, so a
2133
+ * refusal carries `asOfMs` and says what it rested on.
2134
+ */
2135
+ const standing: GatewayStanding | null =
2136
+ position === null || position.coverage === 'none'
2137
+ ? null
2138
+ : {
2139
+ limitUsd: position.limitUsd,
2140
+ consumedUsd: position.consumedUsd,
2141
+ provenance: 'measured',
2142
+ asOfMs: Date.now(),
2143
+ };
2144
+
2145
+ const measured: { calls: number; usd: number } = { calls: 0, usd: 0 };
2146
+ const server = buildGateway({
2147
+ provider,
2148
+ catalogue: pricing,
2149
+ policy: {
2150
+ onCannotTell: policyFlag as FailurePolicy,
2151
+ ...(config.spend?.substitute === undefined ? {} : { substitute: config.spend.substitute }),
2152
+ },
2153
+ standing: () => standing,
2154
+ record: (call) => {
2155
+ measured.calls += 1;
2156
+ console.error(
2157
+ c.dim(
2158
+ t.gateway.measured(
2159
+ call.model,
2160
+ call.label,
2161
+ call.inputTokens,
2162
+ call.outputTokens,
2163
+ call.substituted,
2164
+ ),
2165
+ ),
2166
+ );
2167
+ },
2168
+ note: (line) => {
2169
+ console.error(c.yellow(` ${line}`));
2170
+ },
2171
+ });
2172
+
2173
+ const socket = stringFlag(args, 'socket');
2174
+ const portRaw = stringFlag(args, 'port');
2175
+ const port = portRaw === undefined ? DEFAULT_GATEWAY_PORT : Number(portRaw);
2176
+ if (socket === undefined && (!Number.isInteger(port) || port < 0 || port > 65_535)) {
2177
+ throw new Error(t.serve.badPort(String(portRaw)));
2178
+ }
2179
+
2180
+ const where = await listenGateway(server, socket !== undefined ? { socket } : { port });
2181
+ console.log(c.bold(t.gateway.listening(where, provider)));
2182
+ console.log(` ${c.dim(wrap(t.gateway.pointYourSdk(where), 74, ' '))}`);
2183
+ console.log(` ${c.dim(wrap(t.gateway.credential(), 74, ' '))}`);
2184
+ console.log(` ${c.dim(wrap(t.gateway.neverSubstitutes(), 74, ' '))}`);
2185
+ console.log(
2186
+ ` ${c.dim(wrap(standing === null ? t.gateway.noStanding() : t.gateway.standing(formatUsd(standing.consumedUsd), formatUsd(standing.limitUsd)), 74, ' '))}`,
2187
+ );
2188
+ console.log(` ${c.dim(wrap(t.gateway.policy(policyFlag), 74, ' '))}`);
2189
+ console.log();
2190
+ }
2191
+
2067
2192
  function commandModels(t: CliMessages, pricing: PricingCatalogue): void {
2068
2193
  const n = (value: number): string => value.toLocaleString(t.numberLocale);
2069
2194
  const col = t.models.columns;
@@ -3640,6 +3765,7 @@ async function commandConnect(
3640
3765
  async function commandHistory(
3641
3766
  args: Args,
3642
3767
  config: TrazumConfig,
3768
+ configDir: string,
3643
3769
  pricing: PricingCatalogue,
3644
3770
  t: CliMessages,
3645
3771
  ): Promise<void> {
@@ -3759,7 +3885,7 @@ async function commandHistory(
3759
3885
  * the waiver record belongs to the repository whose gates fired, and the
3760
3886
  * stored reports may have come from anywhere.
3761
3887
  */
3762
- const waivers = await readWaiverLog('.');
3888
+ const waivers = await readWaiverLog(configDir);
3763
3889
  const waiverReport = waiverHistory(waivers.uses, config.waive ?? []);
3764
3890
 
3765
3891
  const stamped = { ...history, unrecognizedFiles: unrecognized, waivers: waiverReport };
@@ -4161,7 +4287,13 @@ async function commandPlan(
4161
4287
  if (outPath !== undefined) console.log(c.dim(wrap(t.plan.wrote(outPath), 74, '')));
4162
4288
  }
4163
4289
 
4164
- async function commandProfile(args: Args, config: TrazumConfig, pricing: PricingCatalogue, t: CliMessages): Promise<void> {
4290
+ async function commandProfile(
4291
+ args: Args,
4292
+ config: TrazumConfig,
4293
+ configDir: string,
4294
+ pricing: PricingCatalogue,
4295
+ t: CliMessages,
4296
+ ): Promise<void> {
4165
4297
  const path = args.positional[0];
4166
4298
  if (path === undefined) {
4167
4299
  console.log();
@@ -4454,6 +4586,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4454
4586
  can(cov.label > 0, t.profile.dryRunLabels(share(cov.label)));
4455
4587
  can(cov.ts > 0, t.profile.dryRunClock(share(cov.ts)));
4456
4588
  can(cov.session > 0, t.profile.dryRunSessions(share(cov.session)));
4589
+ can(cov.outcome > 0, t.profile.dryRunOutcomes(share(cov.outcome)));
4457
4590
  can(cov.stopReason > 0, t.profile.dryRunStopReason(share(cov.stopReason)));
4458
4591
  // "No cache traffic" is not a missing field: the split can only exist on
4459
4592
  // records that wrote, and a log that never wrote has nothing to record.
@@ -4985,7 +5118,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4985
5118
  const recordWaiverUses = async (): Promise<void> => {
4986
5119
  if (waiverUses.length === 0) return;
4987
5120
  for (const use of waiverUses) {
4988
- const failed = await appendWaiverUse('.', use);
5121
+ const failed = await appendWaiverUse(configDir, use);
4989
5122
  if (failed !== null) {
4990
5123
  console.error(c.dim(t.profile.waiveNotRecorded(WAIVER_LOG, failed)));
4991
5124
  return;
@@ -6285,6 +6418,97 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
6285
6418
  * section at all, because a paragraph of things that are fine is the
6286
6419
  * paragraph readers learn to skip.
6287
6420
  */
6421
+ /**
6422
+ * Outcomes — the counterpart, where somebody recorded one.
6423
+ *
6424
+ * Printed above the coverage section rather than below it, because when this
6425
+ * section is present it is the most valuable thing on the page: every other
6426
+ * figure in this report is a cost, and this is the only one that says what
6427
+ * the money bought.
6428
+ *
6429
+ * Silent when nothing recorded an outcome. The coverage section below
6430
+ * already names the missing field and what it would unlock, and printing an
6431
+ * empty Outcomes heading above it would be the same sentence twice.
6432
+ */
6433
+ {
6434
+ const outcomes = outcomeReport(report.outcomeTally, config.outcomes ?? null);
6435
+ if (outcomes.coverage.recorded > 0) {
6436
+ console.log();
6437
+ console.log(c.bold(t.profile.outcomeHeading()));
6438
+
6439
+ const col = t.profile.outcomeColumns;
6440
+ const rows = [...outcomes.slices, ...outcomes.undeclared].map((slice) => ({
6441
+ value: slice.value,
6442
+ verdict:
6443
+ slice.verdict === 'success'
6444
+ ? t.profile.verdictSuccess()
6445
+ : slice.verdict === 'undeclared'
6446
+ ? t.profile.verdictUndeclared()
6447
+ : t.profile.verdictOther(),
6448
+ calls: n(slice.calls),
6449
+ spend: formatUsd(slice.usd),
6450
+ }));
6451
+ const w = {
6452
+ value: Math.max(...rows.map((r) => r.value.length), col.outcome.length),
6453
+ verdict: Math.max(...rows.map((r) => r.verdict.length), 0),
6454
+ calls: Math.max(...rows.map((r) => r.calls.length), col.calls.length),
6455
+ spend: Math.max(...rows.map((r) => r.spend.length), col.spend.length),
6456
+ };
6457
+ console.log(
6458
+ c.dim(
6459
+ ` ${col.outcome.padEnd(w.value)} ${''.padEnd(w.verdict)} ` +
6460
+ `${col.calls.padStart(w.calls)} ${col.spend.padStart(w.spend)}`,
6461
+ ),
6462
+ );
6463
+ for (const row of rows) {
6464
+ const tint =
6465
+ row.verdict === t.profile.verdictUndeclared()
6466
+ ? c.yellow
6467
+ : row.verdict === t.profile.verdictSuccess()
6468
+ ? c.green
6469
+ : c.dim;
6470
+ console.log(
6471
+ ` ${row.value.padEnd(w.value)} ${tint(row.verdict.padEnd(w.verdict))} ` +
6472
+ `${row.calls.padStart(w.calls)} ${row.spend.padStart(w.spend)}`,
6473
+ );
6474
+ }
6475
+
6476
+ console.log();
6477
+ if (outcomes.successShareOfRecordedUsd !== null) {
6478
+ const declaredUsd = outcomes.slices.reduce((sum, slice) => sum + slice.usd, 0);
6479
+ console.log(
6480
+ ` ${wrap(t.profile.outcomeRate(pct(outcomes.successShareOfRecordedUsd), formatUsd(declaredUsd)), 74, ' ')}`,
6481
+ );
6482
+ } else if (outcomes.noRate !== null) {
6483
+ console.log(` ${c.dim(wrap(t.profile.outcomeNoRate(outcomes.noRate), 74, ' '))}`);
6484
+ }
6485
+
6486
+ // What the rate does not cover, every time it is printed. A rate over a
6487
+ // twelfth of the bill is a rate about a twelfth of the bill.
6488
+ if (outcomes.coverage.unrecordedUsd > 0 && report.total.totalUsd > 0) {
6489
+ console.log(
6490
+ ` ${c.yellow('!')} ${wrap(
6491
+ t.profile.outcomeUnrecorded(
6492
+ pct(outcomes.coverage.unrecordedUsd / report.total.totalUsd),
6493
+ formatUsd(outcomes.coverage.unrecordedUsd),
6494
+ ),
6495
+ 74,
6496
+ ' ',
6497
+ )}`,
6498
+ );
6499
+ }
6500
+ if (outcomes.undeclared.length > 0) {
6501
+ console.log(
6502
+ ` ${c.yellow('!')} ${wrap(
6503
+ t.profile.outcomeUndeclared(outcomes.undeclared.map((s) => s.value).join(', ')),
6504
+ 74,
6505
+ ' ',
6506
+ )}`,
6507
+ );
6508
+ }
6509
+ }
6510
+ }
6511
+
6288
6512
  const coverage = report.fieldCoverage;
6289
6513
  if (coverage.parsed > 0) {
6290
6514
  const missing: string[] = [];
@@ -6295,6 +6519,14 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
6295
6519
  if (coverage.session < coverage.parsed) {
6296
6520
  missing.push(t.profile.needsSession(partial(coverage.session)));
6297
6521
  }
6522
+ /**
6523
+ * Listed first among the missing when it is missing entirely, because it
6524
+ * is the one field that changes what every other figure here *means*. The
6525
+ * rest sharpen a cost; this one gives it a counterpart.
6526
+ */
6527
+ if (coverage.outcome < coverage.parsed) {
6528
+ missing.push(t.profile.needsOutcome(partial(coverage.outcome)));
6529
+ }
6298
6530
  if (coverage.ts < coverage.parsed) {
6299
6531
  missing.push(t.profile.needsTs(partial(coverage.ts)));
6300
6532
  }
@@ -8014,6 +8246,22 @@ async function main(): Promise<void> {
8014
8246
  };
8015
8247
  }
8016
8248
  const { config } = loaded;
8249
+ /**
8250
+ * Where the waiver record lives: **beside the config that declared it**.
8251
+ *
8252
+ * It used to be the process's working directory, which is a different place
8253
+ * whenever somebody runs `trazum profile ../logs/x.jsonl --config ../repo/
8254
+ * trazum.config.json` — and that is not hypothetical. This repository's own
8255
+ * test suite did exactly that from `packages/cli`, so sixty records of a
8256
+ * fixture's decisions accumulated in a package directory and one of them was
8257
+ * committed to `main`, where it sat for two releases.
8258
+ *
8259
+ * A waiver is a decision a *repository* made. The record of using it belongs
8260
+ * with the file that made it, not with wherever the terminal happened to be.
8261
+ * No config means no waivers, so there is nothing to write and `.` is never
8262
+ * reached.
8263
+ */
8264
+ const configDir = loaded.path === null ? '.' : dirname(loaded.path);
8017
8265
  const pricing = await pricingFor(args, loaded, t);
8018
8266
 
8019
8267
  // The config only gets to choose the locale when nothing more explicit did.
@@ -8033,7 +8281,7 @@ async function main(): Promise<void> {
8033
8281
  await commandBaseline(args, config, pricing, t, locale);
8034
8282
  break;
8035
8283
  case 'profile':
8036
- await commandProfile(args, config, pricing, t);
8284
+ await commandProfile(args, config, configDir, pricing, t);
8037
8285
  break;
8038
8286
  case 'plan':
8039
8287
  await commandPlan(args, pricing, t);
@@ -8042,7 +8290,7 @@ async function main(): Promise<void> {
8042
8290
  await commandVerify(args, pricing, t);
8043
8291
  break;
8044
8292
  case 'history':
8045
- await commandHistory(args, config, pricing, t);
8293
+ await commandHistory(args, config, configDir, pricing, t);
8046
8294
  break;
8047
8295
  case 'connect':
8048
8296
  await commandConnect(args, pricing, t);
@@ -8071,6 +8319,9 @@ async function main(): Promise<void> {
8071
8319
  case 'models':
8072
8320
  commandModels(t, pricing);
8073
8321
  break;
8322
+ case 'gateway':
8323
+ await commandGateway(args, config, configDir, pricing, t);
8324
+ break;
8074
8325
  case 'feedback':
8075
8326
  commandFeedback(t);
8076
8327
  break;