@trazum/cli 1.39.0 → 1.41.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/connect.d.ts +72 -0
- package/dist/connect.d.ts.map +1 -0
- package/dist/connect.js +204 -0
- package/dist/connect.js.map +1 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +89 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +91 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +52 -0
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.js +279 -40
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/connect.ts +245 -0
- package/src/i18n/en.ts +109 -0
- package/src/i18n/es.ts +111 -0
- package/src/i18n/types.ts +54 -0
- package/src/index.ts +353 -35
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trazum/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.41.0",
|
|
4
4
|
"description": "Trazum CLI: find where your LLM bill goes, price every finding per month, and enforce token budgets in CI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "David Mu\u00f1oz Rey",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"prepublishOnly": "npm run build && npm test"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@trazum/core": "1.
|
|
40
|
+
"@trazum/core": "1.41.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "^26.2.0",
|
package/src/connect.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fetch half of the connector: credentials, pagination, and what went
|
|
3
|
+
* missing.
|
|
4
|
+
*
|
|
5
|
+
* The transformation lives in `@trazum/core`, where it is testable without a
|
|
6
|
+
* network. This module does the part that touches the outside world, and it
|
|
7
|
+
* is written under three rules the rest of the product does not need:
|
|
8
|
+
*
|
|
9
|
+
* **A credential is borrowed, never held.** Keys are read from the environment
|
|
10
|
+
* at the moment of the call and never written to a config, a cache, a report
|
|
11
|
+
* or an error message. `redact` runs over everything that can reach a terminal
|
|
12
|
+
* — a key pasted into a CI log by an error handler is a key that has to be
|
|
13
|
+
* rotated, and the tool that leaked it is the tool that promised to save money.
|
|
14
|
+
*
|
|
15
|
+
* **The endpoint is not user-supplied.** Each provider has one fixed base URL
|
|
16
|
+
* compiled in. Trazum's SSRF story has been, since 1.14, that a request body
|
|
17
|
+
* must never *name* a host — it selects one. A usage connector that accepted
|
|
18
|
+
* `--base-url` would hand that property back for the convenience of a
|
|
19
|
+
* self-hosted proxy nobody has asked for yet.
|
|
20
|
+
*
|
|
21
|
+
* **A partial pull is a partial pull, out loud.** Rate limits, page caps and
|
|
22
|
+
* windows the provider has aged out all return what was gathered, with the
|
|
23
|
+
* gap named. A bill quietly short by an unknown amount is the failure this
|
|
24
|
+
* repository refuses everywhere it can occur, and a paginated API is exactly
|
|
25
|
+
* where it occurs.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { SAFE_FETCH_INIT } from '@trazum/core/node';
|
|
29
|
+
import { normalizeAnthropicUsage, normalizeOpenAIUsage } from '@trazum/core';
|
|
30
|
+
import type { ConnectorDescriptor, ConnectorPull, PullGap } from '@trazum/core';
|
|
31
|
+
|
|
32
|
+
/** Fixed, compiled in, never taken from the caller. See the module note. */
|
|
33
|
+
const ENDPOINTS: Record<string, string> = {
|
|
34
|
+
anthropic: 'https://api.anthropic.com/v1/organizations/usage_report/messages',
|
|
35
|
+
openai: 'https://api.openai.com/v1/organizations/usage/completions',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* How many pages a single pull will walk before it stops and says so.
|
|
40
|
+
*
|
|
41
|
+
* A cap rather than an unbounded loop: a wrong window against a busy
|
|
42
|
+
* organisation is otherwise a request storm against somebody's rate limit,
|
|
43
|
+
* paid for by them. Reaching it is reported as a gap, never as a complete
|
|
44
|
+
* bill.
|
|
45
|
+
*/
|
|
46
|
+
const MAX_PAGES = 50;
|
|
47
|
+
|
|
48
|
+
/** Requests in flight is always one: usage endpoints are strictly rate limited. */
|
|
49
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
50
|
+
|
|
51
|
+
export interface CredentialSource {
|
|
52
|
+
/** The environment variable the key came from — the *name*, never the value. */
|
|
53
|
+
variable: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Finds the credential without ever returning it to a caller that might print
|
|
58
|
+
* it: the key stays inside this module, and the caller gets the variable name.
|
|
59
|
+
*/
|
|
60
|
+
export function findCredential(
|
|
61
|
+
descriptor: ConnectorDescriptor,
|
|
62
|
+
env: Record<string, string | undefined>,
|
|
63
|
+
): { key: string; source: CredentialSource } | null {
|
|
64
|
+
for (const variable of descriptor.credentialEnv) {
|
|
65
|
+
const value = env[variable];
|
|
66
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
67
|
+
return { key: value.trim(), source: { variable } };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Removes credential material from anything on its way to a terminal.
|
|
75
|
+
*
|
|
76
|
+
* Two layers on purpose. The exact key is redacted because we hold it; the
|
|
77
|
+
* shapes are redacted because an error body may quote a *different* key —
|
|
78
|
+
* the one the caller mistyped, a key from a proxy's log line — and a leak
|
|
79
|
+
* through somebody else's error message is still a leak through Trazum's
|
|
80
|
+
* output.
|
|
81
|
+
*/
|
|
82
|
+
export function redact(text: string, key?: string): string {
|
|
83
|
+
let out = text;
|
|
84
|
+
if (key !== undefined && key.length >= 8) {
|
|
85
|
+
out = out.split(key).join('[redacted]');
|
|
86
|
+
}
|
|
87
|
+
return out
|
|
88
|
+
.replace(/sk-ant-[A-Za-z0-9_-]{8,}/g, '[redacted]')
|
|
89
|
+
.replace(/sk-[A-Za-z0-9_-]{16,}/g, '[redacted]')
|
|
90
|
+
.replace(/\bBearer\s+[A-Za-z0-9._-]{8,}/gi, 'Bearer [redacted]');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function headersFor(provider: string, key: string): Record<string, string> {
|
|
94
|
+
if (provider === 'anthropic') {
|
|
95
|
+
return { 'x-api-key': key, 'anthropic-version': '2023-06-01', accept: 'application/json' };
|
|
96
|
+
}
|
|
97
|
+
return { authorization: `Bearer ${key}`, accept: 'application/json' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function urlFor(provider: string, fromMs: number, toMs: number, page: string | null): string {
|
|
101
|
+
const url = new URL(ENDPOINTS[provider]!);
|
|
102
|
+
if (provider === 'anthropic') {
|
|
103
|
+
url.searchParams.set('starting_at', new Date(fromMs).toISOString());
|
|
104
|
+
url.searchParams.set('ending_at', new Date(toMs).toISOString());
|
|
105
|
+
url.searchParams.set('bucket_width', '1d');
|
|
106
|
+
url.searchParams.append('group_by[]', 'model');
|
|
107
|
+
} else {
|
|
108
|
+
url.searchParams.set('start_time', String(Math.floor(fromMs / 1000)));
|
|
109
|
+
url.searchParams.set('end_time', String(Math.floor(toMs / 1000)));
|
|
110
|
+
url.searchParams.set('bucket_width', '1d');
|
|
111
|
+
url.searchParams.append('group_by[]', 'model');
|
|
112
|
+
url.searchParams.set('limit', '31');
|
|
113
|
+
}
|
|
114
|
+
if (page !== null) url.searchParams.set('page', page);
|
|
115
|
+
return url.toString();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface FetchUsageOptions {
|
|
119
|
+
descriptor: ConnectorDescriptor;
|
|
120
|
+
fromMs: number;
|
|
121
|
+
toMs: number;
|
|
122
|
+
env: Record<string, string | undefined>;
|
|
123
|
+
/** Injected so the whole path is testable without a network. */
|
|
124
|
+
fetchImpl?: typeof fetch;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface FetchUsageResult {
|
|
128
|
+
pull: ConnectorPull;
|
|
129
|
+
source: CredentialSource;
|
|
130
|
+
pages: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Pulls a window of usage, page by page, and reports what it could not get.
|
|
135
|
+
*
|
|
136
|
+
* Returns whatever was gathered when a page fails partway through: half a
|
|
137
|
+
* month with the gap named beats an exception that throws away the half that
|
|
138
|
+
* arrived, and beats a total that silently describes less traffic than the
|
|
139
|
+
* caller asked about.
|
|
140
|
+
*/
|
|
141
|
+
export async function fetchProviderUsage(options: FetchUsageOptions): Promise<FetchUsageResult> {
|
|
142
|
+
const { descriptor, fromMs, toMs, env, fetchImpl = fetch } = options;
|
|
143
|
+
const found = findCredential(descriptor, env);
|
|
144
|
+
if (found === null) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`No credential for ${descriptor.displayName}. Trazum reads it from the environment and never stores it — set ${descriptor.credentialEnv.join(' or ')} to ${descriptor.keyKind}. See ${descriptor.docs}.`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const gaps: PullGap[] = [];
|
|
151
|
+
const payloads: unknown[] = [];
|
|
152
|
+
let page: string | null = null;
|
|
153
|
+
let pages = 0;
|
|
154
|
+
|
|
155
|
+
while (pages < MAX_PAGES) {
|
|
156
|
+
const url = urlFor(descriptor.id, fromMs, toMs, page);
|
|
157
|
+
let response: Response;
|
|
158
|
+
try {
|
|
159
|
+
response = await fetchImpl(url, {
|
|
160
|
+
...SAFE_FETCH_INIT,
|
|
161
|
+
method: 'GET',
|
|
162
|
+
headers: headersFor(descriptor.id, found.key),
|
|
163
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
164
|
+
});
|
|
165
|
+
} catch (error) {
|
|
166
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
167
|
+
gaps.push({
|
|
168
|
+
kind: 'rate-limited',
|
|
169
|
+
detail: `the request for page ${pages + 1} did not complete (${redact(message, found.key)}), so everything after it is missing from this window`,
|
|
170
|
+
});
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
pages += 1;
|
|
174
|
+
|
|
175
|
+
if (response.status === 429) {
|
|
176
|
+
gaps.push({
|
|
177
|
+
kind: 'rate-limited',
|
|
178
|
+
detail: `the provider rate-limited page ${pages}, so this window stops early and the rest of it was not measured`,
|
|
179
|
+
});
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
if (response.status === 401 || response.status === 403) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`${descriptor.displayName} refused the credential in ${found.source.variable} (HTTP ${response.status}). This endpoint needs ${descriptor.keyKind}; an ordinary API key cannot read the usage report.`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
if (!response.ok) {
|
|
188
|
+
const body = await response.text().catch(() => '');
|
|
189
|
+
throw new Error(
|
|
190
|
+
`${descriptor.displayName} returned HTTP ${response.status}: ${redact(body.slice(0, 400), found.key) || '(no body)'}`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let payload: unknown;
|
|
195
|
+
try {
|
|
196
|
+
payload = await response.json();
|
|
197
|
+
} catch {
|
|
198
|
+
gaps.push({
|
|
199
|
+
kind: 'unreadable-entry',
|
|
200
|
+
detail: `page ${pages} was not readable JSON, so its buckets are missing from this window`,
|
|
201
|
+
});
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
payloads.push(payload);
|
|
205
|
+
|
|
206
|
+
const more = (payload as { has_more?: unknown }).has_more === true;
|
|
207
|
+
const next = (payload as { next_page?: unknown }).next_page;
|
|
208
|
+
if (!more) break;
|
|
209
|
+
if (typeof next !== 'string' || next === '') {
|
|
210
|
+
gaps.push({
|
|
211
|
+
kind: 'cursor-expired',
|
|
212
|
+
detail: 'the provider said there was more and served no cursor to reach it, so this window is short by an unknown amount',
|
|
213
|
+
});
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
page = next;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (pages >= MAX_PAGES) {
|
|
220
|
+
gaps.push({
|
|
221
|
+
kind: 'page-limit',
|
|
222
|
+
detail: `the pull stopped at ${MAX_PAGES} pages, so this window is incomplete — narrow it with --since and --until`,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const normalize = descriptor.id === 'anthropic' ? normalizeAnthropicUsage : normalizeOpenAIUsage;
|
|
227
|
+
const pulls = payloads.map((payload) => normalize(payload));
|
|
228
|
+
const pull: ConnectorPull = {
|
|
229
|
+
provider: descriptor.id,
|
|
230
|
+
granularity: descriptor.granularity,
|
|
231
|
+
buckets: pulls.flatMap((p) => p.buckets),
|
|
232
|
+
window:
|
|
233
|
+
pulls.length === 0
|
|
234
|
+
? null
|
|
235
|
+
: {
|
|
236
|
+
fromMs: Math.min(...pulls.filter((p) => p.window).map((p) => p.window!.fromMs), Infinity),
|
|
237
|
+
toMs: Math.max(...pulls.filter((p) => p.window).map((p) => p.window!.toMs), -Infinity),
|
|
238
|
+
},
|
|
239
|
+
gaps: [...pulls.flatMap((p) => p.gaps), ...gaps],
|
|
240
|
+
unavailable: descriptor.unavailable,
|
|
241
|
+
};
|
|
242
|
+
if (pull.window !== null && !Number.isFinite(pull.window.fromMs)) pull.window = null;
|
|
243
|
+
|
|
244
|
+
return { pull, source: found.source, pages };
|
|
245
|
+
}
|
package/src/i18n/en.ts
CHANGED
|
@@ -41,6 +41,8 @@ ${bold('USAGE')}
|
|
|
41
41
|
trazum route <log.jsonl> --prompt-file <file> --cases <file> --yes
|
|
42
42
|
trazum plan <log.jsonl|dir> [options]
|
|
43
43
|
trazum verify <plan.json> --against <newer.jsonl|dir> [options]
|
|
44
|
+
trazum history <dir-of-stored-reports> [options]
|
|
45
|
+
trazum connect <anthropic|openai> [options]
|
|
44
46
|
trazum diff <before> <after> [options]
|
|
45
47
|
trazum diff --all <dir> <dir> [options]
|
|
46
48
|
trazum rank <dir> [options]
|
|
@@ -318,6 +320,49 @@ ${bold('OPTIONS FOR plan')}
|
|
|
318
320
|
a plan that hides its assumptions is advice pretending to be arithmetic.
|
|
319
321
|
Projected savings and money already spent are separate totals throughout.
|
|
320
322
|
|
|
323
|
+
${bold('OPTIONS FOR connect')}
|
|
324
|
+
--since <when> The window to pull. A UTC day, an ISO timestamp,
|
|
325
|
+
--until <when> a relative window (7d, 24h) or "now". Defaults to
|
|
326
|
+
the last 30 days.
|
|
327
|
+
--dry-run Say what would be called and which environment
|
|
328
|
+
variable the key would come from. Sends nothing
|
|
329
|
+
and needs no credential.
|
|
330
|
+
--payload <file> Price a usage payload you already have, instead of
|
|
331
|
+
pulling one. No credential, no network — the same
|
|
332
|
+
arithmetic on the same shape.
|
|
333
|
+
-o, --out <file> Save the priced report as JSON.
|
|
334
|
+
--markdown-out <file> Also write it as Markdown, for a CI job summary.
|
|
335
|
+
--json The report as data.
|
|
336
|
+
|
|
337
|
+
Reads your bill from the provider's usage API, so nothing has to be exported
|
|
338
|
+
by hand. The credential is read from the environment at the moment of the
|
|
339
|
+
call and never stored, never printed and never written to a config: set
|
|
340
|
+
TRAZUM_ANTHROPIC_ADMIN_KEY or TRAZUM_OPENAI_ADMIN_KEY. Each provider needs
|
|
341
|
+
the narrowest key that can read a usage report, and an ordinary API key
|
|
342
|
+
cannot.
|
|
343
|
+
|
|
344
|
+
These APIs serve sums over a window, not one row per call, so a connected
|
|
345
|
+
report is a restricted one and says so: the totals, the model split, the day
|
|
346
|
+
series and the cache verdict are all available, and the per-call findings —
|
|
347
|
+
input shapes, truncation retries, conversations, context pressure — are
|
|
348
|
+
listed as unavailable with what would unlock them. A rate limit, a page cap
|
|
349
|
+
or an expired cursor returns what arrived with the gap named, never a total
|
|
350
|
+
that quietly describes less traffic than you asked about.
|
|
351
|
+
|
|
352
|
+
${bold('OPTIONS FOR history')}
|
|
353
|
+
--markdown-out <file> Also write the series as Markdown, for a CI job
|
|
354
|
+
summary or a pull request comment.
|
|
355
|
+
--json The history as data.
|
|
356
|
+
|
|
357
|
+
Takes a directory of stored reports — the --json documents profile already
|
|
358
|
+
writes — plus any saved plans beside them, and builds the series no
|
|
359
|
+
pairwise comparison can see: a workload climbing a little every period, a
|
|
360
|
+
model share rising since a date, a cache share decaying slowly enough that
|
|
361
|
+
no single week's report called it a finding, and the same action planned
|
|
362
|
+
again and again with nothing executing it. Derived from stored reports,
|
|
363
|
+
never re-parsed logs, so a year of JSON is enough and the raw logs can be
|
|
364
|
+
thrown away. Shapes are named; nothing is forecast.
|
|
365
|
+
|
|
321
366
|
${bold('OPTIONS FOR verify')}
|
|
322
367
|
--against <log|dir> The newer usage log the plan is held to. Required.
|
|
323
368
|
--gate Exit 1 when an action did not produce what the
|
|
@@ -1432,6 +1477,70 @@ ${bold('EXAMPLES')}
|
|
|
1432
1477
|
`Plan written to ${path}, dated. Keep it: a prediction nobody wrote down is a prediction nobody can be held to.`,
|
|
1433
1478
|
},
|
|
1434
1479
|
|
|
1480
|
+
connect: {
|
|
1481
|
+
noTarget: (providers) =>
|
|
1482
|
+
`Name a provider to read your bill from: trazum connect anthropic. Available: ${providers}. The credential comes from the environment and is never stored — add --dry-run to see exactly what would be called and which variable it would be read from.`,
|
|
1483
|
+
unknownProvider: (id, providers) =>
|
|
1484
|
+
`There is no connector for "${id}". The ones that exist are: ${providers}.`,
|
|
1485
|
+
dryRun: (provider, from, to, envVars, keyKind) =>
|
|
1486
|
+
`Would read ${provider} usage from ${from} to ${to}, using ${keyKind} taken from ${envVars}. Nothing was sent and no credential was needed to print this.`,
|
|
1487
|
+
heading: (provider, from, to, usd, calls) =>
|
|
1488
|
+
calls === null
|
|
1489
|
+
? `${provider} · ${from} → ${to} · ${usd}`
|
|
1490
|
+
: `${provider} · ${from} → ${to} · ${usd} · ${calls} calls`,
|
|
1491
|
+
modelRow: (model, usd, share, calls) =>
|
|
1492
|
+
calls === null ? `${model} ${usd} ${share}` : `${model} ${usd} ${share} · ${calls} calls`,
|
|
1493
|
+
nothingBilled: () =>
|
|
1494
|
+
'The provider billed nothing in this window. That is a measurement, not an error — widen it with --since if you expected traffic.',
|
|
1495
|
+
cachePaid: (saved) => `Caching paid for itself: ${saved} less than these tokens would have cost as ordinary input.`,
|
|
1496
|
+
cacheLost: (added) => `Caching added ${added} to this bill against what the same tokens would have cost as ordinary input.`,
|
|
1497
|
+
cacheUnsettled: () =>
|
|
1498
|
+
'This source did not say which TTL the cache writes used, so the cheaper rate was assumed and the verdict moves under the other one. Unsettled, not settled in your favour.',
|
|
1499
|
+
noCallCount: (provider) =>
|
|
1500
|
+
`${provider}'s usage report serves token sums and no request count, so there is no call count here and no per-call average. A zero would read as "no traffic", so nothing is printed instead.`,
|
|
1501
|
+
unpriced: (model, tokens) =>
|
|
1502
|
+
`${model} is not in the price catalogue, so its ${tokens} tokens are counted and its money is not. Add it with --pricing rather than reading the total as complete.`,
|
|
1503
|
+
gap: (detail) => `This window is incomplete: ${detail}.`,
|
|
1504
|
+
unavailable: (findings) =>
|
|
1505
|
+
`Findings this source cannot support: ${findings}. They need one row per call, and a sum has lost the rows — a per-call log still answers them.`,
|
|
1506
|
+
wrote: (path) => `Report written to ${path}.`,
|
|
1507
|
+
footer: () =>
|
|
1508
|
+
'Every figure here is the provider\u2019s own billed token count at the catalogue\u2019s rates. Nothing was estimated, and nothing the provider did not serve was filled in.',
|
|
1509
|
+
},
|
|
1510
|
+
|
|
1511
|
+
history: {
|
|
1512
|
+
noTarget: () =>
|
|
1513
|
+
'Point this at a directory of stored reports: trazum history reports/. It reads the --json documents "trazum profile" writes (and any saved plans beside them) and builds the series no pairwise comparison can see.',
|
|
1514
|
+
needsThree: (count) =>
|
|
1515
|
+
`A series needs at least three dated reports, and this directory has ${count}. Two reports is a comparison, and "trazum profile --against" already does that better.`,
|
|
1516
|
+
heading: (periods, from, to) => `The long run: ${periods} periods, ${from} → ${to}`,
|
|
1517
|
+
periodRow: (name, usd, calls, days) => `${name} ${usd} · ${calls} calls · ${days} days`,
|
|
1518
|
+
runLabel: (label, periods, sinceName, from, to) =>
|
|
1519
|
+
`${label} has climbed for ${periods} consecutive periods since ${sinceName}: ${from} → ${to}. A shape, not a forecast.`,
|
|
1520
|
+
runModel: (model, periods, sinceName, from, to) =>
|
|
1521
|
+
`${model}'s share of the bill has climbed for ${periods} consecutive periods since ${sinceName}: ${from} → ${to}. The totals can look flat while the mix moves under them.`,
|
|
1522
|
+
runCache: (periods, sinceName, from, to) =>
|
|
1523
|
+
`The cache share has decayed for ${periods} consecutive periods since ${sinceName}: ${from} → ${to} — slowly enough that no single report called it a finding, which is exactly why a series exists.`,
|
|
1524
|
+
repeated: (kind, label, model, appearances, first, last) => {
|
|
1525
|
+
const what =
|
|
1526
|
+
kind === 'route'
|
|
1527
|
+
? `Routing ${label} (${model})`
|
|
1528
|
+
: kind === 'batch'
|
|
1529
|
+
? `Batching ${label} (${model})`
|
|
1530
|
+
: kind === 'route+batch'
|
|
1531
|
+
? `Routing and batching ${label} (${model})`
|
|
1532
|
+
: kind === 'fix-truncation'
|
|
1533
|
+
? `Fixing the truncation retries on ${label} (${model})`
|
|
1534
|
+
: `Fixing the cache on ${label} (${model})`;
|
|
1535
|
+
const span = first !== null && last !== null ? ` (${first} → ${last})` : '';
|
|
1536
|
+
return `${what} has been planned ${appearances} times${span} and is still in the newest plan — a decision nobody is revisiting.`;
|
|
1537
|
+
},
|
|
1538
|
+
undated: (name) => `${name} carries no span, so it is on no timeline above — named, never silently absorbed.`,
|
|
1539
|
+
unrecognized: (name) => `${name} is neither a stored report nor a saved plan, so it is in no series above.`,
|
|
1540
|
+
footer: () =>
|
|
1541
|
+
'A series names shapes, not futures. Twenty points make a trend visible; they do not make next month knowable — where these lines go next is yours to judge.',
|
|
1542
|
+
},
|
|
1543
|
+
|
|
1435
1544
|
verify: {
|
|
1436
1545
|
noTarget: () =>
|
|
1437
1546
|
'Point this at a saved plan and a newer log: trazum verify plan.json --against usage.jsonl. It says, per action, whether the change arrived, did not arrive, or cannot be told — and never fewer than those three.',
|
package/src/i18n/es.ts
CHANGED
|
@@ -28,6 +28,8 @@ ${bold('USO')}
|
|
|
28
28
|
trazum route <log.jsonl> --prompt-file <fichero> --cases <fichero> --yes
|
|
29
29
|
trazum plan <log.jsonl|dir> [opciones]
|
|
30
30
|
trazum verify <plan.json> --against <nuevo.jsonl|dir> [opciones]
|
|
31
|
+
trazum history <dir-de-informes-guardados> [opciones]
|
|
32
|
+
trazum connect <anthropic|openai> [opciones]
|
|
31
33
|
trazum diff <antes> <después> [opciones]
|
|
32
34
|
trazum diff --all <dir> <dir> [opciones]
|
|
33
35
|
trazum rank <dir> [opciones]
|
|
@@ -325,6 +327,51 @@ ${bold('OPCIONES DE plan')}
|
|
|
325
327
|
consejo haciéndose pasar por aritmética. El ahorro proyectado y el dinero ya
|
|
326
328
|
gastado son totales separados en todas partes.
|
|
327
329
|
|
|
330
|
+
${bold('OPCIONES DE connect')}
|
|
331
|
+
--since <cuándo> La ventana que se descarga. Un día UTC, una marca
|
|
332
|
+
--until <cuándo> ISO, una ventana relativa (7d, 24h) o "now". Por
|
|
333
|
+
defecto, los últimos 30 días.
|
|
334
|
+
--dry-run Dice qué se llamaría y de qué variable de entorno
|
|
335
|
+
saldría la clave. No envía nada y no necesita
|
|
336
|
+
credencial.
|
|
337
|
+
--payload <fichero> Tasa un payload de uso que ya tengas, en vez de
|
|
338
|
+
descargar uno. Sin credencial y sin red — la misma
|
|
339
|
+
aritmética sobre la misma forma.
|
|
340
|
+
-o, --out <fichero> Guarda el informe tasado como JSON.
|
|
341
|
+
--markdown-out <fichero> Lo escribe además como Markdown, para CI.
|
|
342
|
+
--json El informe como datos.
|
|
343
|
+
|
|
344
|
+
Lee tu factura desde la API de uso del proveedor, para que nadie tenga que
|
|
345
|
+
exportar nada a mano. La credencial se lee del entorno en el momento de la
|
|
346
|
+
llamada y nunca se guarda, nunca se imprime y nunca se escribe en un fichero
|
|
347
|
+
de configuración: define TRAZUM_ANTHROPIC_ADMIN_KEY o TRAZUM_OPENAI_ADMIN_KEY.
|
|
348
|
+
Cada proveedor necesita la clave más estrecha que pueda leer un informe de
|
|
349
|
+
uso, y una clave de API normal no puede.
|
|
350
|
+
|
|
351
|
+
Estas APIs sirven sumas sobre una ventana, no una fila por llamada, así que
|
|
352
|
+
un informe conectado es un informe restringido y lo dice: los totales, el
|
|
353
|
+
reparto por modelo, la serie por día y el veredicto de caché están todos
|
|
354
|
+
disponibles, y los hallazgos por llamada — formas de entrada, reintentos por
|
|
355
|
+
truncado, conversaciones, presión de contexto — se listan como no disponibles
|
|
356
|
+
con lo que los desbloquearía. Un límite de tasa, un tope de páginas o un
|
|
357
|
+
cursor caducado devuelven lo que llegó con el hueco nombrado, nunca un total
|
|
358
|
+
que describe en silencio menos tráfico del que pediste.
|
|
359
|
+
|
|
360
|
+
${bold('OPCIONES DE history')}
|
|
361
|
+
--markdown-out <fichero> Escribe además la serie como Markdown, para un
|
|
362
|
+
resumen de CI o un comentario de pull request.
|
|
363
|
+
--json La historia como datos.
|
|
364
|
+
|
|
365
|
+
Toma un directorio de informes guardados — los documentos --json que
|
|
366
|
+
profile ya escribe — más los planes guardados que haya al lado, y construye
|
|
367
|
+
la serie que ninguna comparación por pares puede ver: una carga que sube un
|
|
368
|
+
poco cada período, una cuota de modelo creciendo desde una fecha, una cuota
|
|
369
|
+
de caché decayendo tan despacio que ningún informe semanal lo llamó
|
|
370
|
+
hallazgo, y la misma acción planificada una y otra vez sin que nadie la
|
|
371
|
+
ejecute. Derivada de informes guardados, nunca de registros re-parseados:
|
|
372
|
+
un año de JSON basta y los registros crudos pueden tirarse. Las formas se
|
|
373
|
+
nombran; nada se pronostica.
|
|
374
|
+
|
|
328
375
|
${bold('OPCIONES DE verify')}
|
|
329
376
|
--against <log|dir> El registro de uso posterior al que se somete el
|
|
330
377
|
plan. Obligatorio.
|
|
@@ -1454,6 +1501,70 @@ ${bold('EJEMPLOS')}
|
|
|
1454
1501
|
`Plan escrito en ${path}, con fecha. Guárdalo: una predicción que nadie apuntó es una predicción que no se le puede exigir a nadie.`,
|
|
1455
1502
|
},
|
|
1456
1503
|
|
|
1504
|
+
connect: {
|
|
1505
|
+
noTarget: (providers) =>
|
|
1506
|
+
`Nombra un proveedor del que leer tu factura: trazum connect anthropic. Disponibles: ${providers}. La credencial sale del entorno y nunca se guarda — añade --dry-run para ver exactamente qué se llamaría y de qué variable saldría.`,
|
|
1507
|
+
unknownProvider: (id, providers) =>
|
|
1508
|
+
`No hay conector para "${id}". Los que existen son: ${providers}.`,
|
|
1509
|
+
dryRun: (provider, from, to, envVars, keyKind) =>
|
|
1510
|
+
`Leería el uso de ${provider} del ${from} al ${to}, usando ${keyKind} tomada de ${envVars}. No se envió nada y no hizo falta ninguna credencial para imprimir esto.`,
|
|
1511
|
+
heading: (provider, from, to, usd, calls) =>
|
|
1512
|
+
calls === null
|
|
1513
|
+
? `${provider} · ${from} → ${to} · ${usd}`
|
|
1514
|
+
: `${provider} · ${from} → ${to} · ${usd} · ${calls} llamadas`,
|
|
1515
|
+
modelRow: (model, usd, share, calls) =>
|
|
1516
|
+
calls === null ? `${model} ${usd} ${share}` : `${model} ${usd} ${share} · ${calls} llamadas`,
|
|
1517
|
+
nothingBilled: () =>
|
|
1518
|
+
'El proveedor no facturó nada en esta ventana. Eso es una medición, no un error — ensánchala con --since si esperabas tráfico.',
|
|
1519
|
+
cachePaid: (saved) => `La caché se pagó sola: ${saved} menos de lo que estos tokens habrían costado como entrada normal.`,
|
|
1520
|
+
cacheLost: (added) => `La caché añadió ${added} a esta factura frente a lo que los mismos tokens habrían costado como entrada normal.`,
|
|
1521
|
+
cacheUnsettled: () =>
|
|
1522
|
+
'Esta fuente no dijo con qué TTL se escribió la caché, así que se asumió la tarifa barata y el veredicto cambia con la otra. Sin resolver, no resuelto a tu favor.',
|
|
1523
|
+
noCallCount: (provider) =>
|
|
1524
|
+
`El informe de uso de ${provider} sirve sumas de tokens y ningún recuento de peticiones, así que aquí no hay número de llamadas ni media por llamada. Un cero se leería como "sin tráfico", así que no se imprime nada en su lugar.`,
|
|
1525
|
+
unpriced: (model, tokens) =>
|
|
1526
|
+
`${model} no está en el catálogo de precios, así que sus ${tokens} tokens se cuentan y su dinero no. Añádelo con --pricing en vez de leer el total como completo.`,
|
|
1527
|
+
gap: (detail) => `Esta ventana está incompleta: ${detail}.`,
|
|
1528
|
+
unavailable: (findings) =>
|
|
1529
|
+
`Hallazgos que esta fuente no puede sostener: ${findings}. Necesitan una fila por llamada, y una suma ha perdido las filas — un registro por llamada sí los responde.`,
|
|
1530
|
+
wrote: (path) => `Informe escrito en ${path}.`,
|
|
1531
|
+
footer: () =>
|
|
1532
|
+
'Cada cifra de aquí es el recuento de tokens que el proveedor facturó, a las tarifas del catálogo. No se estimó nada, y no se rellenó nada que el proveedor no sirviera.',
|
|
1533
|
+
},
|
|
1534
|
+
|
|
1535
|
+
history: {
|
|
1536
|
+
noTarget: () =>
|
|
1537
|
+
'Apunta esto a un directorio de informes guardados: trazum history informes/. Lee los documentos --json que escribe "trazum profile" (y los planes guardados que haya al lado) y construye la serie que ninguna comparación por pares puede ver.',
|
|
1538
|
+
needsThree: (count) =>
|
|
1539
|
+
`Una serie necesita al menos tres informes con fecha, y este directorio tiene ${count}. Dos informes son una comparación, y "trazum profile --against" ya la hace mejor.`,
|
|
1540
|
+
heading: (periods, from, to) => `La larga distancia: ${periods} períodos, ${from} → ${to}`,
|
|
1541
|
+
periodRow: (name, usd, calls, days) => `${name} ${usd} · ${calls} llamadas · ${days} días`,
|
|
1542
|
+
runLabel: (label, periods, sinceName, from, to) =>
|
|
1543
|
+
`${label} lleva ${periods} períodos consecutivos subiendo desde ${sinceName}: ${from} → ${to}. Una forma, no un pronóstico.`,
|
|
1544
|
+
runModel: (model, periods, sinceName, from, to) =>
|
|
1545
|
+
`La cuota de ${model} en la factura lleva ${periods} períodos consecutivos subiendo desde ${sinceName}: ${from} → ${to}. Los totales pueden parecer planos mientras la mezcla se mueve debajo.`,
|
|
1546
|
+
runCache: (periods, sinceName, from, to) =>
|
|
1547
|
+
`La cuota de caché lleva ${periods} períodos consecutivos decayendo desde ${sinceName}: ${from} → ${to} — tan despacio que ningún informe suelto lo llamó hallazgo, que es exactamente para lo que existe una serie.`,
|
|
1548
|
+
repeated: (kind, label, model, appearances, first, last) => {
|
|
1549
|
+
const what =
|
|
1550
|
+
kind === 'route'
|
|
1551
|
+
? `Enrutar ${label} (${model})`
|
|
1552
|
+
: kind === 'batch'
|
|
1553
|
+
? `Agrupar en batch ${label} (${model})`
|
|
1554
|
+
: kind === 'route+batch'
|
|
1555
|
+
? `Enrutar y agrupar ${label} (${model})`
|
|
1556
|
+
: kind === 'fix-truncation'
|
|
1557
|
+
? `Arreglar los reintentos por truncado de ${label} (${model})`
|
|
1558
|
+
: `Arreglar la caché de ${label} (${model})`;
|
|
1559
|
+
const span = first !== null && last !== null ? ` (${first} → ${last})` : '';
|
|
1560
|
+
return `${what} se ha planificado ${appearances} veces${span} y sigue en el plan más reciente — una decisión que nadie está revisando.`;
|
|
1561
|
+
},
|
|
1562
|
+
undated: (name) => `${name} no lleva período, así que no está en ninguna línea de tiempo de arriba — nombrado, nunca absorbido en silencio.`,
|
|
1563
|
+
unrecognized: (name) => `${name} no es ni un informe guardado ni un plan guardado, así que no está en ninguna serie de arriba.`,
|
|
1564
|
+
footer: () =>
|
|
1565
|
+
'Una serie nombra formas, no futuros. Veinte puntos hacen visible una tendencia; no hacen conocible el mes que viene — adónde van estas líneas después lo juzgas tú.',
|
|
1566
|
+
},
|
|
1567
|
+
|
|
1457
1568
|
verify: {
|
|
1458
1569
|
noTarget: () =>
|
|
1459
1570
|
'Apunta esto a un plan guardado y a un registro posterior: trazum verify plan.json --against usage.jsonl. Dice, por acción, si el cambio llegó, no llegó o no se puede saber — y nunca menos de esos tres.',
|
package/src/i18n/types.ts
CHANGED
|
@@ -1072,6 +1072,60 @@ export interface CliMessages {
|
|
|
1072
1072
|
wrote(path: string): string;
|
|
1073
1073
|
};
|
|
1074
1074
|
|
|
1075
|
+
/**
|
|
1076
|
+
* `trazum connect` — the bill, read from the provider.
|
|
1077
|
+
*
|
|
1078
|
+
* A usage API serves sums, so the report is restricted on purpose and says
|
|
1079
|
+
* which findings this source cannot support. The credential copy matters as
|
|
1080
|
+
* much as the figures: Trazum borrows a key from the environment and never
|
|
1081
|
+
* stores it, and the messages here are where a user learns that.
|
|
1082
|
+
*/
|
|
1083
|
+
connect: {
|
|
1084
|
+
noTarget(providers: string): string;
|
|
1085
|
+
unknownProvider(id: string, providers: string): string;
|
|
1086
|
+
/** What would be called, with no key needed and nothing sent. */
|
|
1087
|
+
dryRun(provider: string, from: string, to: string, envVars: string, keyKind: string): string;
|
|
1088
|
+
/** `calls` is null on a source that serves no request count. */
|
|
1089
|
+
heading(provider: string, from: string, to: string, usd: string, calls: string | null): string;
|
|
1090
|
+
modelRow(model: string, usd: string, share: string, calls: string | null): string;
|
|
1091
|
+
/** A window the provider billed nothing in — not an error, and not a zero to hide. */
|
|
1092
|
+
nothingBilled(): string;
|
|
1093
|
+
cachePaid(saved: string): string;
|
|
1094
|
+
cacheLost(added: string): string;
|
|
1095
|
+
/** The write TTL was not stated, so the verdict moves under the other rate. */
|
|
1096
|
+
cacheUnsettled(): string;
|
|
1097
|
+
/** This source serves token sums and no request count. */
|
|
1098
|
+
noCallCount(provider: string): string;
|
|
1099
|
+
unpriced(model: string, tokens: string): string;
|
|
1100
|
+
/** Something the pull did not get, named rather than silently missing. */
|
|
1101
|
+
gap(detail: string): string;
|
|
1102
|
+
unavailable(findings: string): string;
|
|
1103
|
+
wrote(path: string): string;
|
|
1104
|
+
footer(): string;
|
|
1105
|
+
};
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* `trazum history` — many reports over many periods, as one series.
|
|
1109
|
+
*
|
|
1110
|
+
* Shapes are named as consecutive movement, never a fitted line, and no
|
|
1111
|
+
* series becomes a forecast: where the line goes next stays the reader's.
|
|
1112
|
+
*/
|
|
1113
|
+
history: {
|
|
1114
|
+
noTarget(): string;
|
|
1115
|
+
/** Under three dated reports there is no series — only the comparison --against already does. */
|
|
1116
|
+
needsThree(count: string): string;
|
|
1117
|
+
heading(periods: string, from: string, to: string): string;
|
|
1118
|
+
periodRow(name: string, usd: string, calls: string, days: string): string;
|
|
1119
|
+
runLabel(label: string, periods: string, sinceName: string, from: string, to: string): string;
|
|
1120
|
+
runModel(model: string, periods: string, sinceName: string, from: string, to: string): string;
|
|
1121
|
+
runCache(periods: string, sinceName: string, from: string, to: string): string;
|
|
1122
|
+
/** The same action in plan after plan: a decision nobody is executing. */
|
|
1123
|
+
repeated(kind: PlanActionKind, label: string, model: string, appearances: string, first: string | null, last: string | null): string;
|
|
1124
|
+
undated(name: string): string;
|
|
1125
|
+
unrecognized(name: string): string;
|
|
1126
|
+
footer(): string;
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1075
1129
|
/**
|
|
1076
1130
|
* `trazum verify` — the plan held to the log that came after it.
|
|
1077
1131
|
*
|