@trazum/cli 1.42.0 → 1.44.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/README.md +2 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +95 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +100 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +42 -1
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.js +226 -1
- package/dist/index.js.map +1 -1
- package/dist/serve.d.ts +65 -0
- package/dist/serve.d.ts.map +1 -0
- package/dist/serve.js +115 -0
- package/dist/serve.js.map +1 -0
- package/dist/watch-run.d.ts +69 -0
- package/dist/watch-run.d.ts.map +1 -0
- package/dist/watch-run.js +79 -0
- package/dist/watch-run.js.map +1 -0
- package/package.json +2 -2
- package/src/i18n/en.ts +110 -0
- package/src/i18n/es.ts +115 -0
- package/src/i18n/types.ts +44 -1
- package/src/index.ts +301 -0
- package/src/serve.ts +149 -0
- package/src/watch-run.ts +126 -0
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, bucketedCacheEconomics, bucketedProfile, buildHistory, buildPlan, connectorFor, CONNECTORS, normalizeAnthropicUsage, normalizeOpenAIUsage, bucketsFromRecords, pruneRecords, recordsFromBuckets, storeInventory, 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';
|
|
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, 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
|
|
@@ -11,6 +11,8 @@ import { CONFIG_FILENAME, DEFAULT_EXTENSIONS, budgetFor, BUNDLED_CATALOGUE, SAFE
|
|
|
11
11
|
import { contentAt, gitAvailable, namesByRevision, pathInRepository, repositoryRoot, revisionsFor, } from './git.js';
|
|
12
12
|
import { fetchProviderUsage } from './connect.js';
|
|
13
13
|
import { STORE_DIR, appendRecords, readStore, rewriteStore } from './store-fs.js';
|
|
14
|
+
import { DEFAULT_PORT, buildServer, listen } from './serve.js';
|
|
15
|
+
import { WATCH_STATE_VERSION, checkWebhook, postWebhook, readWatchState, writeWatchState, } from './watch-run.js';
|
|
14
16
|
import { detectLocale, getCliMessages } from './i18n/index.js';
|
|
15
17
|
import { MAX_SUMMARY_CHARS, fitWithin, renderBlameMarkdown, renderCheckMarkdown, renderDiffMarkdown, renderRankMarkdown, renderProfileMarkdown, } from './markdown.js';
|
|
16
18
|
// --------------------------------------------------------------------------
|
|
@@ -31,6 +33,10 @@ const VALUE_FLAGS = new Set([
|
|
|
31
33
|
'min-usd',
|
|
32
34
|
'payload',
|
|
33
35
|
'keep',
|
|
36
|
+
'interval',
|
|
37
|
+
'webhook',
|
|
38
|
+
'port',
|
|
39
|
+
'socket',
|
|
34
40
|
// `route` takes a path here, and the flag is deliberately not `--prompt`:
|
|
35
41
|
// everywhere else in this tool `--prompt` names a marked prompt *inside* a
|
|
36
42
|
// source file, and reusing it for a path would be a trap laid for the reader.
|
|
@@ -295,6 +301,8 @@ const COMMAND_FLAGS = {
|
|
|
295
301
|
history: ['store', 'json', 'markdown-out'],
|
|
296
302
|
connect: ['since', 'until', 'payload', 'store', 'json', 'out', 'markdown-out', 'pricing', 'pricing-live', 'dry-run'],
|
|
297
303
|
store: ['prune', 'keep', 'json', 'pricing', 'pricing-live', 'dry-run'],
|
|
304
|
+
watch: ['once', 'interval', 'since', 'payload', 'webhook', 'json', 'pricing', 'pricing-live'],
|
|
305
|
+
serve: ['port', 'socket', 'pricing', 'pricing-live'],
|
|
298
306
|
route: ['prompt-file', 'cases', 'label', 'concurrency', 'json', 'yes', 'pricing', 'pricing-live'],
|
|
299
307
|
eval: ['cases', 'level', 'concurrency', 'export', 'out', 'o', 'model'],
|
|
300
308
|
prune: ['cases', 'concurrency', 'json', 'yes'],
|
|
@@ -1689,6 +1697,217 @@ function parseWhen(args, flag, endOfDay, t, now) {
|
|
|
1689
1697
|
return { ms: exact, relative: false };
|
|
1690
1698
|
throw new Error(t.profile.badWhen(flag, value));
|
|
1691
1699
|
}
|
|
1700
|
+
/**
|
|
1701
|
+
* `trazum serve` — the answer in milliseconds.
|
|
1702
|
+
*
|
|
1703
|
+
* The measured position is read once at start rather than per request: the
|
|
1704
|
+
* whole promise is a single-digit-millisecond answer, and a file read in the
|
|
1705
|
+
* hot path cannot make it. That staleness is real, so every answer carries the
|
|
1706
|
+
* window its measurement covers instead of implying it is current to the
|
|
1707
|
+
* second.
|
|
1708
|
+
*/
|
|
1709
|
+
async function commandServe(args, config, pricing, t) {
|
|
1710
|
+
const root = process.cwd();
|
|
1711
|
+
const limitUsd = config.spend?.maxUsd;
|
|
1712
|
+
const { resolved } = await readStore(root);
|
|
1713
|
+
const measured = resolved.records.length > 0;
|
|
1714
|
+
/**
|
|
1715
|
+
* The window the measurement covers, carried into every answer.
|
|
1716
|
+
*
|
|
1717
|
+
* The position is read once at start, so a caller has to be able to see how
|
|
1718
|
+
* old it is. A null window here would let a figure from last month read as
|
|
1719
|
+
* current, which is the staleness this endpoint is otherwise honest about.
|
|
1720
|
+
*/
|
|
1721
|
+
const window = measured
|
|
1722
|
+
? {
|
|
1723
|
+
fromMs: Math.min(...resolved.records.map((record) => record.fromMs)),
|
|
1724
|
+
toMs: Math.max(...resolved.records.map((record) => record.toMs)),
|
|
1725
|
+
}
|
|
1726
|
+
: null;
|
|
1727
|
+
const report = bucketedProfile({
|
|
1728
|
+
provider: 'store',
|
|
1729
|
+
granularity: 'bucketed',
|
|
1730
|
+
buckets: bucketsFromRecords(resolved.records),
|
|
1731
|
+
window,
|
|
1732
|
+
gaps: [],
|
|
1733
|
+
unavailable: [],
|
|
1734
|
+
}, { catalogue: pricing });
|
|
1735
|
+
const server = buildServer({
|
|
1736
|
+
catalogue: pricing,
|
|
1737
|
+
position: () => ({
|
|
1738
|
+
consumedUsd: measured ? report.total.totalUsd : undefined,
|
|
1739
|
+
limitUsd,
|
|
1740
|
+
window: report.span,
|
|
1741
|
+
}),
|
|
1742
|
+
});
|
|
1743
|
+
const socket = stringFlag(args, 'socket');
|
|
1744
|
+
const portRaw = stringFlag(args, 'port');
|
|
1745
|
+
const port = portRaw === undefined ? DEFAULT_PORT : Number(portRaw);
|
|
1746
|
+
if (socket === undefined && (!Number.isInteger(port) || port < 0 || port > 65_535)) {
|
|
1747
|
+
throw new Error(t.serve.badPort(String(portRaw)));
|
|
1748
|
+
}
|
|
1749
|
+
const where = await listen(server, socket !== undefined ? { socket } : { port });
|
|
1750
|
+
console.log(c.bold(t.serve.listening(where)));
|
|
1751
|
+
console.log(` ${c.dim(wrap(t.serve.loopbackOnly(), 74, ' '))}`);
|
|
1752
|
+
console.log(` ${c.dim(wrap(measured ? t.serve.measuredFrom(formatUsd(report.total.totalUsd)) : t.serve.nothingMeasured(STORE_DIR), 74, ' '))}`);
|
|
1753
|
+
if (limitUsd === undefined) {
|
|
1754
|
+
console.log(` ${c.dim(wrap(t.serve.noBudget(), 74, ' '))}`);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
/**
|
|
1758
|
+
* `trazum watch` — the afternoon it happened, said that afternoon.
|
|
1759
|
+
*
|
|
1760
|
+
* One cycle is the primitive: measure, keep, evaluate, emit, remember. The
|
|
1761
|
+
* loop is that cycle in a timer, so a cron entry and a foreground watcher run
|
|
1762
|
+
* exactly the same code and the tests exercise the thing that ships.
|
|
1763
|
+
*
|
|
1764
|
+
* Three transports, all boring on purpose: a non-zero exit code so cron mails
|
|
1765
|
+
* it, a JSON event on stdout so any pipeline can read it, and a webhook for
|
|
1766
|
+
* the operator who already has somewhere for alerts to go. No hosted service
|
|
1767
|
+
* and no account.
|
|
1768
|
+
*/
|
|
1769
|
+
async function commandWatch(args, config, pricing, t) {
|
|
1770
|
+
const root = process.cwd();
|
|
1771
|
+
const asJson = boolFlag(args, 'json');
|
|
1772
|
+
const n = (value) => value.toLocaleString(t.numberLocale);
|
|
1773
|
+
const day = (msValue) => new Date(msValue).toISOString().slice(0, 10);
|
|
1774
|
+
const thresholds = {
|
|
1775
|
+
maxUsd: config.spend?.maxUsd,
|
|
1776
|
+
maxDayUsd: config.spend?.maxDayUsd,
|
|
1777
|
+
maxCacheLossUsd: config.spend?.maxCacheLossUsd,
|
|
1778
|
+
};
|
|
1779
|
+
if (thresholds.maxUsd === undefined &&
|
|
1780
|
+
thresholds.maxDayUsd === undefined &&
|
|
1781
|
+
thresholds.maxCacheLossUsd === undefined) {
|
|
1782
|
+
throw new Error(t.watch.noThresholds());
|
|
1783
|
+
}
|
|
1784
|
+
/**
|
|
1785
|
+
* A webhook is a new outbound surface, so it is checked before anything is
|
|
1786
|
+
* sent: credentials in a URL end up in logs and shell history, and an alert
|
|
1787
|
+
* carrying spend figures over plain http across a network is a leak the
|
|
1788
|
+
* operator did not ask for. Loopback is the exception, because pointing a
|
|
1789
|
+
* watcher at your own alerting daemon is the ordinary case.
|
|
1790
|
+
*/
|
|
1791
|
+
const webhookRaw = stringFlag(args, 'webhook');
|
|
1792
|
+
let webhook = null;
|
|
1793
|
+
if (webhookRaw !== undefined) {
|
|
1794
|
+
const checked = checkWebhook(webhookRaw);
|
|
1795
|
+
if (!checked.ok)
|
|
1796
|
+
throw new Error(t.watch.badWebhook(checked.reason));
|
|
1797
|
+
webhook = checked.url;
|
|
1798
|
+
}
|
|
1799
|
+
const intervalRaw = stringFlag(args, 'interval');
|
|
1800
|
+
const once = boolFlag(args, 'once') || intervalRaw === undefined;
|
|
1801
|
+
let intervalMs = 0;
|
|
1802
|
+
if (!once) {
|
|
1803
|
+
const match = /^(\d+)(m|h)$/.exec(intervalRaw);
|
|
1804
|
+
const amount = match === null ? NaN : Number(match[1]);
|
|
1805
|
+
intervalMs = match?.[2] === 'h' ? amount * 3_600_000 : amount * 60_000;
|
|
1806
|
+
// Usage APIs are rate limited, and a tight loop is a way to get somebody's
|
|
1807
|
+
// key throttled by a tool that was supposed to save them money.
|
|
1808
|
+
if (!Number.isFinite(intervalMs) || intervalMs < 5 * 60_000) {
|
|
1809
|
+
throw new Error(t.watch.intervalTooTight());
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
const cycle = async () => {
|
|
1813
|
+
const state = await readWatchState(root);
|
|
1814
|
+
const nowMs = Date.now();
|
|
1815
|
+
/**
|
|
1816
|
+
* Where the measurements come from: a saved payload when one is named
|
|
1817
|
+
* (which is how this is tested and how an air-gapped run works), and the
|
|
1818
|
+
* store otherwise. A cycle that found nothing to measure says so — a
|
|
1819
|
+
* watcher over nothing is a green light nobody earned.
|
|
1820
|
+
*/
|
|
1821
|
+
const payloadPath = stringFlag(args, 'payload');
|
|
1822
|
+
let pull;
|
|
1823
|
+
if (payloadPath !== undefined) {
|
|
1824
|
+
pull = normalizeAnthropicUsage(JSON.parse(await readFile(payloadPath, 'utf8')));
|
|
1825
|
+
}
|
|
1826
|
+
else {
|
|
1827
|
+
const { resolved } = await readStore(root);
|
|
1828
|
+
if (resolved.records.length === 0)
|
|
1829
|
+
throw new Error(t.watch.nothingToWatch(STORE_DIR));
|
|
1830
|
+
pull = {
|
|
1831
|
+
provider: 'store',
|
|
1832
|
+
granularity: 'bucketed',
|
|
1833
|
+
buckets: bucketsFromRecords(resolved.records),
|
|
1834
|
+
window: null,
|
|
1835
|
+
gaps: [],
|
|
1836
|
+
unavailable: [],
|
|
1837
|
+
};
|
|
1838
|
+
}
|
|
1839
|
+
const report = bucketedProfile(pull, { catalogue: pricing });
|
|
1840
|
+
const cache = bucketedCacheEconomics(report);
|
|
1841
|
+
const result = evaluateWatch({
|
|
1842
|
+
report,
|
|
1843
|
+
thresholds,
|
|
1844
|
+
cacheDeltaUsd: cache.verdict === 'no-cache' ? undefined : cache.deltaUsd,
|
|
1845
|
+
nowMs,
|
|
1846
|
+
lastCoveredToMs: state?.lastCoveredToMs ?? undefined,
|
|
1847
|
+
alreadyFired: new Set(Object.keys(state?.fired ?? {})),
|
|
1848
|
+
});
|
|
1849
|
+
if (asJson) {
|
|
1850
|
+
console.log(JSON.stringify({ schemaVersion: 1, firedAtMs: nowMs, ...result }, null, 2));
|
|
1851
|
+
}
|
|
1852
|
+
else {
|
|
1853
|
+
if (result.gap !== null) {
|
|
1854
|
+
console.log(c.yellow(wrap(t.watch.gap(day(result.gap.fromMs), day(result.gap.toMs)), 76, ' ')));
|
|
1855
|
+
}
|
|
1856
|
+
for (const crossing of result.crossings) {
|
|
1857
|
+
console.log(c.red(wrap(t.watch.crossed(crossing.gate, formatUsd(crossing.measuredUsd), formatUsd(crossing.limitUsd), crossing.day), 76, ' ')));
|
|
1858
|
+
}
|
|
1859
|
+
for (const abstention of result.abstentions) {
|
|
1860
|
+
console.log(c.dim(wrap(t.watch.notJudgeable(abstention.gate, abstention.reason, abstention.detail === null
|
|
1861
|
+
? null
|
|
1862
|
+
: `${Math.round((abstention.detail.coveredMs / abstention.detail.neededMs) * 100)}%`), 76, ' ')));
|
|
1863
|
+
}
|
|
1864
|
+
for (const still of result.suppressed) {
|
|
1865
|
+
console.log(c.yellow(wrap(t.watch.stillOver(still.gate, formatUsd(still.measuredUsd), formatUsd(still.limitUsd), still.day), 76, ' ')));
|
|
1866
|
+
}
|
|
1867
|
+
if (result.crossings.length === 0 &&
|
|
1868
|
+
result.suppressed.length === 0 &&
|
|
1869
|
+
result.abstentions.length === 0) {
|
|
1870
|
+
console.log(c.green(wrap(t.watch.allWithin(n(Object.keys(thresholds).filter((k) => thresholds[k] !== undefined).length)), 76, ' ')));
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
if (webhook !== null && result.crossings.length > 0) {
|
|
1874
|
+
const sent = await postWebhook(webhook, {
|
|
1875
|
+
schemaVersion: 1,
|
|
1876
|
+
firedAtMs: nowMs,
|
|
1877
|
+
crossings: result.crossings,
|
|
1878
|
+
});
|
|
1879
|
+
if (!sent.ok) {
|
|
1880
|
+
// Reported and swallowed: the exit code and the event already carried
|
|
1881
|
+
// the crossing, and losing those because a receiver is down would make
|
|
1882
|
+
// the quietest failure the loudest one.
|
|
1883
|
+
console.error(c.yellow(t.watch.webhookFailed(sent.status === null ? sent.error ?? '' : String(sent.status))));
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
const fired = { ...(state?.fired ?? {}) };
|
|
1887
|
+
for (const crossing of result.crossings)
|
|
1888
|
+
fired[firedKey(crossing.gate, crossing.day)] = nowMs;
|
|
1889
|
+
await writeWatchState(root, {
|
|
1890
|
+
v: WATCH_STATE_VERSION,
|
|
1891
|
+
lastCycleMs: nowMs,
|
|
1892
|
+
lastCoveredToMs: report.span?.toMs ?? state?.lastCoveredToMs ?? null,
|
|
1893
|
+
fired,
|
|
1894
|
+
});
|
|
1895
|
+
return result.crossings.length + result.suppressed.length;
|
|
1896
|
+
};
|
|
1897
|
+
const crossed = await cycle();
|
|
1898
|
+
// Still over is still a failure: only the alert was already sent.
|
|
1899
|
+
if (crossed > 0)
|
|
1900
|
+
process.exitCode = 1;
|
|
1901
|
+
if (once)
|
|
1902
|
+
return;
|
|
1903
|
+
console.log(c.dim(t.watch.watching(String(Math.round(intervalMs / 60_000)))));
|
|
1904
|
+
// The loop is the cycle in a timer and nothing more, so the primitive above
|
|
1905
|
+
// is the only thing that ever needs testing.
|
|
1906
|
+
for (;;) {
|
|
1907
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
1908
|
+
await cycle();
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1692
1911
|
/**
|
|
1693
1912
|
* `trazum store` — what is kept, and what a prune would take.
|
|
1694
1913
|
*
|
|
@@ -5468,6 +5687,12 @@ async function main() {
|
|
|
5468
5687
|
case 'store':
|
|
5469
5688
|
await commandStore(args, config, pricing, t);
|
|
5470
5689
|
break;
|
|
5690
|
+
case 'watch':
|
|
5691
|
+
await commandWatch(args, config, pricing, t);
|
|
5692
|
+
break;
|
|
5693
|
+
case 'serve':
|
|
5694
|
+
await commandServe(args, config, pricing, t);
|
|
5695
|
+
break;
|
|
5471
5696
|
case 'route':
|
|
5472
5697
|
await commandRoute(args, pricing, t);
|
|
5473
5698
|
break;
|