@trazum/cli 1.40.0 → 1.42.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 +99 -1
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +101 -1
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +61 -1
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.js +379 -72
- package/dist/index.js.map +1 -1
- package/dist/store-fs.d.ts +59 -0
- package/dist/store-fs.d.ts.map +1 -0
- package/dist/store-fs.js +145 -0
- package/dist/store-fs.js.map +1 -0
- package/package.json +2 -2
- package/src/connect.ts +245 -0
- package/src/i18n/en.ts +125 -1
- package/src/i18n/es.ts +127 -1
- package/src/i18n/types.ts +63 -1
- package/src/index.ts +503 -67
- package/src/store-fs.ts +157 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the store actually lives.
|
|
3
|
+
*
|
|
4
|
+
* The core decides what a record is and when two are the same; this decides
|
|
5
|
+
* where the bytes go. Split that way for the reason every module here is:
|
|
6
|
+
* `@trazum/core` stays browser-safe, and the CLI keeps its monopoly on I/O.
|
|
7
|
+
*
|
|
8
|
+
* **Append-only, one buffer per write.** A pull appends a single block and
|
|
9
|
+
* never rewrites what is already there. Two consequences worth stating: a
|
|
10
|
+
* crash during a write loses the tail of one block rather than a year of
|
|
11
|
+
* measurements, and two runs writing at once interleave whole blocks rather
|
|
12
|
+
* than half-lines. Compaction is a separate, explicit errand — `store
|
|
13
|
+
* --prune` — because collapsing a log is the one operation that destroys
|
|
14
|
+
* something, and it should never happen as a side effect of a pull.
|
|
15
|
+
*
|
|
16
|
+
* **A line that will not parse is kept, counted and skipped.** The store is a
|
|
17
|
+
* file a human may open, a backup may truncate and a merge may mangle. Losing
|
|
18
|
+
* the whole month because one line is broken would be the worst possible
|
|
19
|
+
* response; so would silently pretending the month is complete.
|
|
20
|
+
*/
|
|
21
|
+
import type { ResolvedStore, StoreRecord } from '@trazum/core';
|
|
22
|
+
/** The directory name, relative to wherever the caller roots the store. */
|
|
23
|
+
export declare const STORE_DIR = ".trazum/store";
|
|
24
|
+
export interface StoreReadResult {
|
|
25
|
+
resolved: ResolvedStore;
|
|
26
|
+
/** Lines that would not parse: counted and named by file, never dropped quietly. */
|
|
27
|
+
unreadable: {
|
|
28
|
+
file: string;
|
|
29
|
+
line: number;
|
|
30
|
+
}[];
|
|
31
|
+
/** Files read, so an empty store can be told from an unread one. */
|
|
32
|
+
files: string[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Reads every record in the store.
|
|
36
|
+
*
|
|
37
|
+
* Returns an empty result rather than throwing when the store does not exist:
|
|
38
|
+
* "you have not stored anything yet" is a state, not an error, and the caller
|
|
39
|
+
* says so in a sentence that names `trazum connect`.
|
|
40
|
+
*/
|
|
41
|
+
export declare function readStore(root: string): Promise<StoreReadResult>;
|
|
42
|
+
/**
|
|
43
|
+
* Appends records, grouped into one write per month file.
|
|
44
|
+
*
|
|
45
|
+
* Nothing already on disk is read, rewritten or resolved here: convergence
|
|
46
|
+
* happens when the store is *read*, which is what keeps a write cheap enough
|
|
47
|
+
* to run on a schedule and impossible to corrupt by racing.
|
|
48
|
+
*/
|
|
49
|
+
export declare function appendRecords(root: string, records: readonly StoreRecord[]): Promise<number>;
|
|
50
|
+
/**
|
|
51
|
+
* Rewrites the store with exactly the records given.
|
|
52
|
+
*
|
|
53
|
+
* The one operation that destroys something, so it is only ever reached from
|
|
54
|
+
* an explicit `--prune`. Each month file is written whole, and a month left
|
|
55
|
+
* with nothing is written empty rather than removed — a missing file and an
|
|
56
|
+
* empty one say different things to whoever looks next.
|
|
57
|
+
*/
|
|
58
|
+
export declare function rewriteStore(root: string, records: readonly StoreRecord[]): Promise<void>;
|
|
59
|
+
//# sourceMappingURL=store-fs.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store-fs.d.ts","sourceRoot":"","sources":["../src/store-fs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAKH,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAE/D,2EAA2E;AAC3E,eAAO,MAAM,SAAS,kBAAkB,CAAC;AAOzC,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,aAAa,CAAC;IACxB,oFAAoF;IACpF,UAAU,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC7C,oEAAoE;IACpE,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED;;;;;;GAMG;AACH,wBAAsB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC,CA2CtE;AAED;;;;;;GAMG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,WAAW,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAiBlG;AAED;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,WAAW,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA4B/F"}
|
package/dist/store-fs.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the store actually lives.
|
|
3
|
+
*
|
|
4
|
+
* The core decides what a record is and when two are the same; this decides
|
|
5
|
+
* where the bytes go. Split that way for the reason every module here is:
|
|
6
|
+
* `@trazum/core` stays browser-safe, and the CLI keeps its monopoly on I/O.
|
|
7
|
+
*
|
|
8
|
+
* **Append-only, one buffer per write.** A pull appends a single block and
|
|
9
|
+
* never rewrites what is already there. Two consequences worth stating: a
|
|
10
|
+
* crash during a write loses the tail of one block rather than a year of
|
|
11
|
+
* measurements, and two runs writing at once interleave whole blocks rather
|
|
12
|
+
* than half-lines. Compaction is a separate, explicit errand — `store
|
|
13
|
+
* --prune` — because collapsing a log is the one operation that destroys
|
|
14
|
+
* something, and it should never happen as a side effect of a pull.
|
|
15
|
+
*
|
|
16
|
+
* **A line that will not parse is kept, counted and skipped.** The store is a
|
|
17
|
+
* file a human may open, a backup may truncate and a merge may mangle. Losing
|
|
18
|
+
* the whole month because one line is broken would be the worst possible
|
|
19
|
+
* response; so would silently pretending the month is complete.
|
|
20
|
+
*/
|
|
21
|
+
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { resolveStore } from '@trazum/core';
|
|
24
|
+
/** The directory name, relative to wherever the caller roots the store. */
|
|
25
|
+
export const STORE_DIR = '.trazum/store';
|
|
26
|
+
/** Records are filed by the UTC month their window starts in. */
|
|
27
|
+
function monthOf(record) {
|
|
28
|
+
return new Date(record.fromMs).toISOString().slice(0, 7);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Reads every record in the store.
|
|
32
|
+
*
|
|
33
|
+
* Returns an empty result rather than throwing when the store does not exist:
|
|
34
|
+
* "you have not stored anything yet" is a state, not an error, and the caller
|
|
35
|
+
* says so in a sentence that names `trazum connect`.
|
|
36
|
+
*/
|
|
37
|
+
export async function readStore(root) {
|
|
38
|
+
const dir = join(root, STORE_DIR);
|
|
39
|
+
const records = [];
|
|
40
|
+
const unreadable = [];
|
|
41
|
+
const files = [];
|
|
42
|
+
let providers;
|
|
43
|
+
try {
|
|
44
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
45
|
+
providers = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return { resolved: resolveStore([]), unreadable, files };
|
|
49
|
+
}
|
|
50
|
+
for (const provider of providers.sort()) {
|
|
51
|
+
const providerDir = join(dir, provider);
|
|
52
|
+
let months;
|
|
53
|
+
try {
|
|
54
|
+
months = (await readdir(providerDir)).filter((name) => name.endsWith('.jsonl')).sort();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
for (const month of months) {
|
|
60
|
+
const path = join(providerDir, month);
|
|
61
|
+
files.push(join(STORE_DIR, provider, month));
|
|
62
|
+
const text = await readFile(path, 'utf8');
|
|
63
|
+
for (const [index, line] of text.split('\n').entries()) {
|
|
64
|
+
if (line.trim() === '')
|
|
65
|
+
continue;
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(line);
|
|
68
|
+
if (typeof parsed?.provider === 'string' && typeof parsed?.fromMs === 'number') {
|
|
69
|
+
records.push(parsed);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
unreadable.push({ file: join(STORE_DIR, provider, month), line: index + 1 });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
unreadable.push({ file: join(STORE_DIR, provider, month), line: index + 1 });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { resolved: resolveStore(records), unreadable, files };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Appends records, grouped into one write per month file.
|
|
85
|
+
*
|
|
86
|
+
* Nothing already on disk is read, rewritten or resolved here: convergence
|
|
87
|
+
* happens when the store is *read*, which is what keeps a write cheap enough
|
|
88
|
+
* to run on a schedule and impossible to corrupt by racing.
|
|
89
|
+
*/
|
|
90
|
+
export async function appendRecords(root, records) {
|
|
91
|
+
if (records.length === 0)
|
|
92
|
+
return 0;
|
|
93
|
+
const byFile = new Map();
|
|
94
|
+
for (const record of records) {
|
|
95
|
+
const key = join(record.provider, `${monthOf(record)}.jsonl`);
|
|
96
|
+
const list = byFile.get(key) ?? [];
|
|
97
|
+
list.push(record);
|
|
98
|
+
byFile.set(key, list);
|
|
99
|
+
}
|
|
100
|
+
for (const [relative, list] of byFile) {
|
|
101
|
+
const path = join(root, STORE_DIR, relative);
|
|
102
|
+
await mkdir(join(path, '..'), { recursive: true });
|
|
103
|
+
const block = `${list.map((record) => JSON.stringify(record)).join('\n')}\n`;
|
|
104
|
+
await writeFile(path, block, { flag: 'a', mode: 0o600 });
|
|
105
|
+
}
|
|
106
|
+
return records.length;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Rewrites the store with exactly the records given.
|
|
110
|
+
*
|
|
111
|
+
* The one operation that destroys something, so it is only ever reached from
|
|
112
|
+
* an explicit `--prune`. Each month file is written whole, and a month left
|
|
113
|
+
* with nothing is written empty rather than removed — a missing file and an
|
|
114
|
+
* empty one say different things to whoever looks next.
|
|
115
|
+
*/
|
|
116
|
+
export async function rewriteStore(root, records) {
|
|
117
|
+
const dir = join(root, STORE_DIR);
|
|
118
|
+
const existing = new Set();
|
|
119
|
+
try {
|
|
120
|
+
for (const provider of await readdir(dir)) {
|
|
121
|
+
for (const month of await readdir(join(dir, provider)).catch(() => [])) {
|
|
122
|
+
if (month.endsWith('.jsonl'))
|
|
123
|
+
existing.add(join(provider, month));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Nothing stored yet: the writes below create what is needed.
|
|
129
|
+
}
|
|
130
|
+
const byFile = new Map();
|
|
131
|
+
for (const record of records) {
|
|
132
|
+
const key = join(record.provider, `${monthOf(record)}.jsonl`);
|
|
133
|
+
const list = byFile.get(key) ?? [];
|
|
134
|
+
list.push(record);
|
|
135
|
+
byFile.set(key, list);
|
|
136
|
+
}
|
|
137
|
+
for (const relative of new Set([...existing, ...byFile.keys()])) {
|
|
138
|
+
const list = byFile.get(relative) ?? [];
|
|
139
|
+
const path = join(dir, relative);
|
|
140
|
+
await mkdir(join(path, '..'), { recursive: true });
|
|
141
|
+
const block = list.length === 0 ? '' : `${list.map((r) => JSON.stringify(r)).join('\n')}\n`;
|
|
142
|
+
await writeFile(path, block, { mode: 0o600 });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
//# sourceMappingURL=store-fs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store-fs.js","sourceRoot":"","sources":["../src/store-fs.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trazum/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.42.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.42.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
|
@@ -42,6 +42,8 @@ ${bold('USAGE')}
|
|
|
42
42
|
trazum plan <log.jsonl|dir> [options]
|
|
43
43
|
trazum verify <plan.json> --against <newer.jsonl|dir> [options]
|
|
44
44
|
trazum history <dir-of-stored-reports> [options]
|
|
45
|
+
trazum connect <anthropic|openai> [options]
|
|
46
|
+
trazum store [--prune] [options]
|
|
45
47
|
trazum diff <before> <after> [options]
|
|
46
48
|
trazum diff --all <dir> <dir> [options]
|
|
47
49
|
trazum rank <dir> [options]
|
|
@@ -319,7 +321,64 @@ ${bold('OPTIONS FOR plan')}
|
|
|
319
321
|
a plan that hides its assumptions is advice pretending to be arithmetic.
|
|
320
322
|
Projected savings and money already spent are separate totals throughout.
|
|
321
323
|
|
|
324
|
+
${bold('OPTIONS FOR store')}
|
|
325
|
+
--prune Drop measurements older than the retention
|
|
326
|
+
policy, and compact the append log to what the
|
|
327
|
+
store already resolves to. Says what went.
|
|
328
|
+
--keep <n>d Retention for this run, when the config has none.
|
|
329
|
+
--dry-run With --prune: say what would go, and delete
|
|
330
|
+
nothing.
|
|
331
|
+
--json The inventory as data.
|
|
332
|
+
|
|
333
|
+
Says what the local store holds: how many measurements, over what span, per
|
|
334
|
+
provider, and what a prune would take. The store keeps aggregates and
|
|
335
|
+
billing fields — never prompt text, never completion text, never a
|
|
336
|
+
credential — so it is a file a team can back up without a privacy review.
|
|
337
|
+
|
|
338
|
+
Pruning is the one operation here that destroys something, so it refuses to
|
|
339
|
+
run without a retention policy: set "store": {"keepDays": 90} in the config
|
|
340
|
+
or pass --keep. Deleting measurements on a policy nobody wrote down is not a
|
|
341
|
+
default anybody should get by accident.
|
|
342
|
+
|
|
343
|
+
${bold('OPTIONS FOR connect')}
|
|
344
|
+
--since <when> The window to pull. A UTC day, an ISO timestamp,
|
|
345
|
+
--until <when> a relative window (7d, 24h) or "now". Defaults to
|
|
346
|
+
the last 30 days.
|
|
347
|
+
--dry-run Say what would be called and which environment
|
|
348
|
+
variable the key would come from. Sends nothing
|
|
349
|
+
and needs no credential.
|
|
350
|
+
--payload <file> Price a usage payload you already have, instead of
|
|
351
|
+
pulling one. No credential, no network — the same
|
|
352
|
+
arithmetic on the same shape.
|
|
353
|
+
--store Keep what was pulled in the local store, so the
|
|
354
|
+
next run does not download it again and "trazum
|
|
355
|
+
history --store" has a series.
|
|
356
|
+
-o, --out <file> Save the priced report as JSON.
|
|
357
|
+
--markdown-out <file> Also write it as Markdown, for a CI job summary.
|
|
358
|
+
--json The report as data.
|
|
359
|
+
|
|
360
|
+
Reads your bill from the provider's usage API, so nothing has to be exported
|
|
361
|
+
by hand. The credential is read from the environment at the moment of the
|
|
362
|
+
call and never stored, never printed and never written to a config: set
|
|
363
|
+
TRAZUM_ANTHROPIC_ADMIN_KEY or TRAZUM_OPENAI_ADMIN_KEY. Each provider needs
|
|
364
|
+
the narrowest key that can read a usage report, and an ordinary API key
|
|
365
|
+
cannot.
|
|
366
|
+
|
|
367
|
+
These APIs serve sums over a window, not one row per call, so a connected
|
|
368
|
+
report is a restricted one and says so: the totals, the model split, the day
|
|
369
|
+
series and the cache verdict are all available, and the per-call findings —
|
|
370
|
+
input shapes, truncation retries, conversations, context pressure — are
|
|
371
|
+
listed as unavailable with what would unlock them. A rate limit, a page cap
|
|
372
|
+
or an expired cursor returns what arrived with the gap named, never a total
|
|
373
|
+
that quietly describes less traffic than you asked about.
|
|
374
|
+
|
|
322
375
|
${bold('OPTIONS FOR history')}
|
|
376
|
+
--store Build the series from the local store instead of
|
|
377
|
+
a directory of stored reports. Bucketed sources
|
|
378
|
+
carry no label, so the label series is absent and
|
|
379
|
+
said to be — the model-share and cache-share
|
|
380
|
+
series are what a series exists for, and both
|
|
381
|
+
work.
|
|
323
382
|
--markdown-out <file> Also write the series as Markdown, for a CI job
|
|
324
383
|
summary or a pull request comment.
|
|
325
384
|
--json The history as data.
|
|
@@ -1447,13 +1506,76 @@ ${bold('EXAMPLES')}
|
|
|
1447
1506
|
`Plan written to ${path}, dated. Keep it: a prediction nobody wrote down is a prediction nobody can be held to.`,
|
|
1448
1507
|
},
|
|
1449
1508
|
|
|
1509
|
+
store: {
|
|
1510
|
+
appended: (count, dir) => `Kept ${count} measurements in ${dir}.`,
|
|
1511
|
+
empty: (dir) =>
|
|
1512
|
+
`The store at ${dir} is empty. Fill it with "trazum connect <provider> --store" — that is a state, not an error.`,
|
|
1513
|
+
heading: (records, usd, from, to) =>
|
|
1514
|
+
`The store: ${records} measurements · ${usd} · ${from} → ${to}`,
|
|
1515
|
+
providerRow: (provider, records, span, models) =>
|
|
1516
|
+
`${provider} ${records} measurements · ${span} · ${models} models`,
|
|
1517
|
+
holds: (files) =>
|
|
1518
|
+
`Held in ${files} files: token counts, billed dollars and the account's own workspace and key identifiers. Never prompt text, never completion text, never a credential — this is a file you can back up without a privacy review.`,
|
|
1519
|
+
possiblyDouble: (count) =>
|
|
1520
|
+
`${count} records could not be told apart from another — a window of no length, or a record naming no model. They are kept whole rather than merged, so a total built on them may count the same spend twice. Saying so beats a smaller number nobody can check.`,
|
|
1521
|
+
unknownVersion: (count) =>
|
|
1522
|
+
`${count} records come from a newer schema than this version knows, so they are kept and left out of the figures above rather than guessed at. Upgrade to read them.`,
|
|
1523
|
+
unreadable: (file, line) =>
|
|
1524
|
+
`${file} line ${line} would not parse, so it is not in the figures above. The rest of the file was read — one broken line must not lose a month.`,
|
|
1525
|
+
retention: (days) => `Retention: ${days} days, from "store.keepDays". Run "trazum store --prune" to apply it.`,
|
|
1526
|
+
noRetention: () =>
|
|
1527
|
+
'No retention policy is configured, so nothing is ever deleted on its own. Set "store": {"keepDays": 90} when you want one.',
|
|
1528
|
+
pruneNeedsPolicy: () =>
|
|
1529
|
+
'Pruning needs a retention policy: set "store": {"keepDays": 90} in trazum.config.json, or pass --keep 90d for this run. Deleting measurements on a policy nobody wrote down is not a default you should get by accident.',
|
|
1530
|
+
pruneDryRun: (count, days, span, usd) =>
|
|
1531
|
+
span === null
|
|
1532
|
+
? `Nothing is older than ${days} days, so a prune would delete nothing.`
|
|
1533
|
+
: `A prune would delete ${count} measurements older than ${days} days, covering ${span} and ${usd} of measured spend. Nothing was deleted — this was --dry-run.`,
|
|
1534
|
+
pruned: (count, days, span, usd, kept) =>
|
|
1535
|
+
span === null
|
|
1536
|
+
? `Nothing was older than ${days} days. ${kept} measurements kept, and the append log compacted.`
|
|
1537
|
+
: `Deleted ${count} measurements older than ${days} days, covering ${span} and ${usd} of measured spend. ${kept} kept, and the append log compacted to what the store already resolved to.`,
|
|
1538
|
+
},
|
|
1539
|
+
|
|
1540
|
+
connect: {
|
|
1541
|
+
noTarget: (providers) =>
|
|
1542
|
+
`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.`,
|
|
1543
|
+
unknownProvider: (id, providers) =>
|
|
1544
|
+
`There is no connector for "${id}". The ones that exist are: ${providers}.`,
|
|
1545
|
+
dryRun: (provider, from, to, envVars, keyKind) =>
|
|
1546
|
+
`Would read ${provider} usage from ${from} to ${to}, using ${keyKind} taken from ${envVars}. Nothing was sent and no credential was needed to print this.`,
|
|
1547
|
+
heading: (provider, from, to, usd, calls) =>
|
|
1548
|
+
calls === null
|
|
1549
|
+
? `${provider} · ${from} → ${to} · ${usd}`
|
|
1550
|
+
: `${provider} · ${from} → ${to} · ${usd} · ${calls} calls`,
|
|
1551
|
+
modelRow: (model, usd, share, calls) =>
|
|
1552
|
+
calls === null ? `${model} ${usd} ${share}` : `${model} ${usd} ${share} · ${calls} calls`,
|
|
1553
|
+
nothingBilled: () =>
|
|
1554
|
+
'The provider billed nothing in this window. That is a measurement, not an error — widen it with --since if you expected traffic.',
|
|
1555
|
+
cachePaid: (saved) => `Caching paid for itself: ${saved} less than these tokens would have cost as ordinary input.`,
|
|
1556
|
+
cacheLost: (added) => `Caching added ${added} to this bill against what the same tokens would have cost as ordinary input.`,
|
|
1557
|
+
cacheUnsettled: () =>
|
|
1558
|
+
'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.',
|
|
1559
|
+
noCallCount: (provider) =>
|
|
1560
|
+
`${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.`,
|
|
1561
|
+
unpriced: (model, tokens) =>
|
|
1562
|
+
`${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.`,
|
|
1563
|
+
gap: (detail) => `This window is incomplete: ${detail}.`,
|
|
1564
|
+
unavailable: (findings) =>
|
|
1565
|
+
`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.`,
|
|
1566
|
+
wrote: (path) => `Report written to ${path}.`,
|
|
1567
|
+
footer: () =>
|
|
1568
|
+
'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.',
|
|
1569
|
+
},
|
|
1570
|
+
|
|
1450
1571
|
history: {
|
|
1451
1572
|
noTarget: () =>
|
|
1452
1573
|
'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.',
|
|
1453
1574
|
needsThree: (count) =>
|
|
1454
1575
|
`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.`,
|
|
1455
1576
|
heading: (periods, from, to) => `The long run: ${periods} periods, ${from} → ${to}`,
|
|
1456
|
-
periodRow: (name, usd, calls, days) =>
|
|
1577
|
+
periodRow: (name, usd, calls, days) =>
|
|
1578
|
+
calls === null ? `${name} ${usd} · ${days} days` : `${name} ${usd} · ${calls} calls · ${days} days`,
|
|
1457
1579
|
runLabel: (label, periods, sinceName, from, to) =>
|
|
1458
1580
|
`${label} has climbed for ${periods} consecutive periods since ${sinceName}: ${from} → ${to}. A shape, not a forecast.`,
|
|
1459
1581
|
runModel: (model, periods, sinceName, from, to) =>
|
|
@@ -1474,6 +1596,8 @@ ${bold('EXAMPLES')}
|
|
|
1474
1596
|
const span = first !== null && last !== null ? ` (${first} → ${last})` : '';
|
|
1475
1597
|
return `${what} has been planned ${appearances} times${span} and is still in the newest plan — a decision nobody is revisiting.`;
|
|
1476
1598
|
},
|
|
1599
|
+
storeNoLabels: () =>
|
|
1600
|
+
'This series comes from the store, and a usage API groups by model and workspace rather than by workload — so there is no label series here at all. Absent, not empty: nothing above says a workload did or did not move.',
|
|
1477
1601
|
undated: (name) => `${name} carries no span, so it is on no timeline above — named, never silently absorbed.`,
|
|
1478
1602
|
unrecognized: (name) => `${name} is neither a stored report nor a saved plan, so it is in no series above.`,
|
|
1479
1603
|
footer: () =>
|