@vimoxshah/tokenflow 1.1.2 → 1.2.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/CHANGELOG.md +180 -0
- package/Dockerfile.team +20 -0
- package/README.md +30 -11
- package/bin/tokenflow.js +147 -12
- package/design/tokens.yaml +330 -0
- package/docs/architecture.md +5 -4
- package/docs/cli.md +204 -0
- package/docs/configuration.md +117 -2
- package/docs/design-system.md +187 -0
- package/docs/exports-and-budgets.md +85 -0
- package/docs/guard-codex.md +132 -0
- package/docs/ledger.md +144 -0
- package/docs/live-mode.md +40 -0
- package/docs/media/overview-aurora-dark.png +0 -0
- package/docs/media/receipts-aurora-dark.png +0 -0
- package/docs/providers-otel.md +179 -0
- package/docs/providers.md +54 -1
- package/docs/receipt-schema.md +74 -0
- package/docs/roadmap.md +182 -0
- package/docs/team-server.md +170 -0
- package/docs/ui-views.md +322 -0
- package/package.json +7 -2
- package/schemas/receipt.v0.json +160 -0
- package/scripts/build-menubar-app.sh +3 -1
- package/scripts/design-build.js +475 -0
- package/src/analytics/anatomy.js +467 -0
- package/src/analytics/branch-compare.js +159 -0
- package/src/analytics/cache-health.js +141 -0
- package/src/analytics/live-view.js +266 -0
- package/src/analytics/receipt-schema.js +214 -0
- package/src/analytics/receipt.js +709 -0
- package/src/analytics/rhythm.js +184 -0
- package/src/analytics/whatif.js +263 -0
- package/src/commands/budget-scopes.js +133 -0
- package/src/commands/doctor-checks.js +400 -0
- package/src/commands/guard.js +531 -0
- package/src/commands/hooks.js +238 -0
- package/src/commands/pricing-diff.js +316 -0
- package/src/commands/receipt.js +226 -0
- package/src/commands/team-serve.js +407 -0
- package/src/commands/week.js +86 -0
- package/src/core/annotations.js +97 -0
- package/src/core/budget.js +33 -0
- package/src/core/bundle.js +45 -2
- package/src/core/ingest.js +33 -0
- package/src/core/live-status.js +227 -2
- package/src/core/policy.js +103 -0
- package/src/core/receipt-note.js +123 -0
- package/src/core/repo.js +64 -0
- package/src/core/sync.js +163 -26
- package/src/core/team.js +0 -0
- package/src/export/html-snapshot.js +28 -1
- package/src/export/menubar.js +21 -0
- package/src/export/receipt-card.js +210 -0
- package/src/export/week-card.js +185 -0
- package/src/providers/mock/index.js +383 -52
- package/src/providers/openai/index.js +31 -1
- package/src/providers/otel/index.js +656 -0
- package/src/server/routes/annotations.js +42 -0
- package/src/server/routes/cache-health.js +95 -0
- package/src/server/routes/index.js +54 -0
- package/src/server/routes/session.js +157 -0
- package/src/server/server.js +47 -1
- package/src/ui/app.js +541 -308
- package/src/ui/charts.js +95 -0
- package/src/ui/first-run.js +144 -0
- package/src/ui/index.html +4 -1
- package/src/ui/palette.js +335 -0
- package/src/ui/styles/anatomy.css +117 -0
- package/src/ui/styles/annotations.css +40 -0
- package/src/ui/styles/branches.css +99 -0
- package/src/ui/styles/cache.css +6 -0
- package/src/ui/styles/first-run.css +31 -0
- package/src/ui/styles/live.css +100 -0
- package/src/ui/styles/palette.css +85 -0
- package/src/ui/styles/rhythm.css +8 -0
- package/src/ui/styles/whatif.css +55 -0
- package/src/ui/styles.css +303 -196
- package/src/ui/views/anatomy.js +567 -0
- package/src/ui/views/annotations.js +121 -0
- package/src/ui/views/branches.js +304 -0
- package/src/ui/views/cache.js +232 -0
- package/src/ui/views/index.js +85 -0
- package/src/ui/views/live.js +683 -0
- package/src/ui/views/rhythm.js +206 -0
- package/src/ui/views/whatif.js +196 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tokenflow hooks` — a pre-push git hook that attaches a TokenFlow receipt
|
|
3
|
+
* to the commit being pushed, as a git note, with no server involved.
|
|
4
|
+
*
|
|
5
|
+
* tokenflow hooks install write .git/hooks/pre-push
|
|
6
|
+
* tokenflow hooks uninstall remove it, restoring anything it replaced
|
|
7
|
+
* tokenflow hooks status installed? chained? where?
|
|
8
|
+
* tokenflow hooks pre-push <remote> <url> the hook body (reads stdin)
|
|
9
|
+
*
|
|
10
|
+
* `pre-push` is never invoked by a person: it is what the installed script
|
|
11
|
+
* calls (see renderHookScript()). It must never block a push of its own
|
|
12
|
+
* accord — every failure here is reported on stderr with exit 0, and the
|
|
13
|
+
* installed shell script forces exit 0 after this step regardless, as a
|
|
14
|
+
* second line of defense. A hook this replaced is kept as
|
|
15
|
+
* `pre-push.tokenflow-chained` and always runs first, keeping its own exit
|
|
16
|
+
* code: that gate belongs to whoever configured it, not to TokenFlow.
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { repoRootOf } from '../core/repo.js';
|
|
22
|
+
import { buildBranchReceipt, writeNote, pushNotes } from '../core/receipt-note.js';
|
|
23
|
+
|
|
24
|
+
const HOOK_NAME = 'pre-push';
|
|
25
|
+
const CHAINED_NAME = 'pre-push.tokenflow-chained';
|
|
26
|
+
const MARKER = '# tokenflow:pre-push v1 -- installed by `tokenflow hooks install`';
|
|
27
|
+
const ALL_ZERO_SHA = '0'.repeat(40);
|
|
28
|
+
|
|
29
|
+
function cliPath() {
|
|
30
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
return path.resolve(here, '..', '..', 'bin', 'tokenflow.js');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hooksDirFor(repoPath) {
|
|
35
|
+
const root = repoRootOf(repoPath) || repoPath;
|
|
36
|
+
return path.join(root, '.git', 'hooks');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The installed hook script's contents. Buffers stdin once (so it can be
|
|
41
|
+
* replayed to a chained hook and to the tokenflow step), runs a chained hook
|
|
42
|
+
* first and keeps its exit code, then runs the tokenflow step and always
|
|
43
|
+
* exits 0 after it.
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
export function renderHookScript() {
|
|
47
|
+
const node = process.execPath;
|
|
48
|
+
const cli = cliPath();
|
|
49
|
+
return `#!/bin/sh
|
|
50
|
+
${MARKER}
|
|
51
|
+
# Re-run \`tokenflow hooks install\` / \`tokenflow hooks uninstall\` to change this file by hand.
|
|
52
|
+
|
|
53
|
+
# The notes push below re-triggers this same hook; stop immediately instead of recursing.
|
|
54
|
+
if [ "$TOKENFLOW_HOOK_NESTED" = "1" ]; then
|
|
55
|
+
exit 0
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
TOKENFLOW_STDIN="$(mktemp)"
|
|
59
|
+
trap 'rm -f "$TOKENFLOW_STDIN"' EXIT
|
|
60
|
+
cat > "$TOKENFLOW_STDIN"
|
|
61
|
+
|
|
62
|
+
HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
63
|
+
if [ -x "$HOOK_DIR/${CHAINED_NAME}" ]; then
|
|
64
|
+
"$HOOK_DIR/${CHAINED_NAME}" "$@" < "$TOKENFLOW_STDIN"
|
|
65
|
+
chained_status=$?
|
|
66
|
+
if [ "$chained_status" -ne 0 ]; then
|
|
67
|
+
exit "$chained_status"
|
|
68
|
+
fi
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
if [ -x "${node}" ]; then
|
|
72
|
+
"${node}" "${cli}" hooks pre-push "$@" < "$TOKENFLOW_STDIN"
|
|
73
|
+
else
|
|
74
|
+
echo "tokenflow: node not found at ${node}; skipping receipt" >&2
|
|
75
|
+
fi
|
|
76
|
+
exit 0
|
|
77
|
+
`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** True when the file at `p` is a hook TokenFlow installed (carries our marker). */
|
|
81
|
+
function isOurs(p) {
|
|
82
|
+
try {
|
|
83
|
+
return fs.readFileSync(p, 'utf8').includes(MARKER);
|
|
84
|
+
} catch {
|
|
85
|
+
return false; // unreadable: treat as foreign so it is never silently overwritten
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `tokenflow hooks install` — write the pre-push hook. A pre-existing hook
|
|
91
|
+
* that is not already ours is kept as `pre-push.tokenflow-chained`.
|
|
92
|
+
* @param {{repo?:string}} [flags]
|
|
93
|
+
* @returns {{path:string, chained:boolean}}
|
|
94
|
+
*/
|
|
95
|
+
export function install(flags = {}) {
|
|
96
|
+
const repoPath = flags.repo || process.cwd();
|
|
97
|
+
const dir = hooksDirFor(repoPath);
|
|
98
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
99
|
+
const hookPath = path.join(dir, HOOK_NAME);
|
|
100
|
+
const chainedPath = path.join(dir, CHAINED_NAME);
|
|
101
|
+
let chained = fs.existsSync(chainedPath);
|
|
102
|
+
if (fs.existsSync(hookPath) && !isOurs(hookPath)) {
|
|
103
|
+
fs.copyFileSync(hookPath, chainedPath);
|
|
104
|
+
fs.chmodSync(chainedPath, 0o755);
|
|
105
|
+
chained = true;
|
|
106
|
+
}
|
|
107
|
+
fs.writeFileSync(hookPath, renderHookScript());
|
|
108
|
+
fs.chmodSync(hookPath, 0o755);
|
|
109
|
+
return { path: hookPath, chained };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `tokenflow hooks uninstall` — remove our hook, restoring a chained one.
|
|
114
|
+
* Leaves a foreign (non-tokenflow) hook untouched.
|
|
115
|
+
* @param {{repo?:string}} [flags]
|
|
116
|
+
* @returns {{path:string, restored:boolean}}
|
|
117
|
+
*/
|
|
118
|
+
export function uninstall(flags = {}) {
|
|
119
|
+
const repoPath = flags.repo || process.cwd();
|
|
120
|
+
const dir = hooksDirFor(repoPath);
|
|
121
|
+
const hookPath = path.join(dir, HOOK_NAME);
|
|
122
|
+
const chainedPath = path.join(dir, CHAINED_NAME);
|
|
123
|
+
let restored = false;
|
|
124
|
+
if (fs.existsSync(hookPath) && isOurs(hookPath)) {
|
|
125
|
+
fs.unlinkSync(hookPath);
|
|
126
|
+
if (fs.existsSync(chainedPath)) {
|
|
127
|
+
fs.renameSync(chainedPath, hookPath);
|
|
128
|
+
fs.chmodSync(hookPath, 0o755);
|
|
129
|
+
restored = true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { path: hookPath, restored };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `tokenflow hooks status` — installed? chained? where?
|
|
137
|
+
* @param {{repo?:string}} [flags]
|
|
138
|
+
* @returns {{installed:boolean, chained:boolean, path:string}}
|
|
139
|
+
*/
|
|
140
|
+
export function status(flags = {}) {
|
|
141
|
+
const repoPath = flags.repo || process.cwd();
|
|
142
|
+
const dir = hooksDirFor(repoPath);
|
|
143
|
+
const hookPath = path.join(dir, HOOK_NAME);
|
|
144
|
+
const chainedPath = path.join(dir, CHAINED_NAME);
|
|
145
|
+
return {
|
|
146
|
+
installed: fs.existsSync(hookPath) && isOurs(hookPath),
|
|
147
|
+
chained: fs.existsSync(chainedPath),
|
|
148
|
+
path: hookPath,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function readStdin(flags) {
|
|
153
|
+
if (typeof flags.stdin === 'string') return flags.stdin;
|
|
154
|
+
try {
|
|
155
|
+
return fs.readFileSync(0, 'utf8');
|
|
156
|
+
} catch {
|
|
157
|
+
return ''; // no stdin piped in (e.g. a manual run outside of git)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The `pre-push` hook body. Reads git's standard stdin lines
|
|
163
|
+
* (`<local ref> <local sha> <remote ref> <remote sha>`), attaches a receipt
|
|
164
|
+
* note to each pushed branch's local sha, then pushes the notes ref once, if
|
|
165
|
+
* anything was written. Never blocks: every failure is reported on stderr
|
|
166
|
+
* with exit 0.
|
|
167
|
+
* @param {{repo?:string, args?:string[], stdin?:string}} [flags]
|
|
168
|
+
* @returns {{stdout:string|null, stderr:string|null, exitCode:number}}
|
|
169
|
+
*/
|
|
170
|
+
export function prePush(flags = {}) {
|
|
171
|
+
if (process.env.TOKENFLOW_HOOK_NESTED === '1') {
|
|
172
|
+
// This is the notes push this same command triggers; do not recurse.
|
|
173
|
+
return { stdout: null, stderr: null, exitCode: 0 };
|
|
174
|
+
}
|
|
175
|
+
const repoPath = flags.repo || process.cwd();
|
|
176
|
+
const remote = (flags.args && flags.args[0]) || 'origin';
|
|
177
|
+
const stdin = readStdin(flags);
|
|
178
|
+
const errors = [];
|
|
179
|
+
let wrote = 0;
|
|
180
|
+
|
|
181
|
+
for (const line of stdin.split('\n')) {
|
|
182
|
+
const t = line.trim();
|
|
183
|
+
if (!t) continue;
|
|
184
|
+
const parts = t.split(/\s+/);
|
|
185
|
+
if (parts.length !== 4) continue; // not a well-formed ref-update line
|
|
186
|
+
const [localRef, localSha] = parts;
|
|
187
|
+
if (!localRef.startsWith('refs/heads/')) continue; // tags and other refs carry no branch receipt
|
|
188
|
+
if (localSha === ALL_ZERO_SHA) continue; // a delete: nothing to attach a receipt to
|
|
189
|
+
const branch = localRef.slice('refs/heads/'.length);
|
|
190
|
+
try {
|
|
191
|
+
const receipt = buildBranchReceipt({ repoPath, branch, sha: localSha });
|
|
192
|
+
if (!receipt) continue; // no local sessions for this branch: nothing to attach
|
|
193
|
+
writeNote({ repoPath, sha: localSha, receipt });
|
|
194
|
+
wrote += 1;
|
|
195
|
+
} catch (err) {
|
|
196
|
+
errors.push(`${branch}: ${err.message}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (wrote > 0) {
|
|
201
|
+
try {
|
|
202
|
+
pushNotes({ repoPath, remote });
|
|
203
|
+
} catch (err) {
|
|
204
|
+
errors.push(`push refs/notes/tokenflow: ${err.message}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (errors.length) return { stdout: null, stderr: `tokenflow: ${errors.join('; ')}`, exitCode: 0 };
|
|
209
|
+
return { stdout: null, stderr: null, exitCode: 0 };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* CLI entry. Dispatches on `flags.action` (`install` / `uninstall` /
|
|
214
|
+
* `status` / `pre-push`), set by the bin from the `hooks <action>` argv.
|
|
215
|
+
* @param {object} flags
|
|
216
|
+
* @returns {{stdout:string|null, stderr:string|null, exitCode:number}}
|
|
217
|
+
*/
|
|
218
|
+
export function run(flags = {}) {
|
|
219
|
+
switch (flags.action) {
|
|
220
|
+
case 'install': {
|
|
221
|
+
const r = install(flags);
|
|
222
|
+
const chainedNote = r.chained ? ` (chained the existing hook as ${CHAINED_NAME})` : '';
|
|
223
|
+
return { stdout: `installed ${r.path}${chainedNote}`, stderr: null, exitCode: 0 };
|
|
224
|
+
}
|
|
225
|
+
case 'uninstall': {
|
|
226
|
+
const r = uninstall(flags);
|
|
227
|
+
return { stdout: `uninstalled${r.restored ? `; restored the previous hook at ${r.path}` : ''}`, stderr: null, exitCode: 0 };
|
|
228
|
+
}
|
|
229
|
+
case 'status': {
|
|
230
|
+
const s = status(flags);
|
|
231
|
+
return { stdout: `installed: ${s.installed ? 'yes' : 'no'} chained: ${s.chained ? 'yes' : 'no'} ${s.path}`, stderr: null, exitCode: 0 };
|
|
232
|
+
}
|
|
233
|
+
case 'pre-push':
|
|
234
|
+
return prePush(flags);
|
|
235
|
+
default:
|
|
236
|
+
return { stdout: null, stderr: 'usage: tokenflow hooks <install|uninstall|status|pre-push>', exitCode: 1 };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tokenflow pricing diff <file.json|-> [--apply] [--yes]`
|
|
3
|
+
*
|
|
4
|
+
* Compares a candidate price table to the current EFFECTIVE book — the
|
|
5
|
+
* existing overrides file (`paths().pricing`) layered on the built-in table,
|
|
6
|
+
* exactly what `buildPriceBook` resolves for every other command — and prints
|
|
7
|
+
* what would change: models the candidate adds, rates it changes (with the
|
|
8
|
+
* percent change per field), and the current overrides it says nothing about
|
|
9
|
+
* (which `--apply` never touches, because apply merges, it does not replace).
|
|
10
|
+
*
|
|
11
|
+
* The candidate is the same shape the overrides file already uses today
|
|
12
|
+
* (`{ models: { "<model>": { in, out, cacheRead?, cacheWrite?, cacheRefresh?,
|
|
13
|
+
* match? } } }`, see src/core/pricing.js#buildPriceBook), plus two optional
|
|
14
|
+
* top-level fields for provenance: `sources: [string]` and `version: string`.
|
|
15
|
+
* Neither is written to the overrides file on apply — the overrides file's
|
|
16
|
+
* shape stays exactly what `tokenflow pricing --set` already produces
|
|
17
|
+
* (`models` + `updatedAt`), so nothing downstream has to learn a new field.
|
|
18
|
+
*
|
|
19
|
+
* `--apply` always prints the diff first. It then requires either `--yes`,
|
|
20
|
+
* or an interactive terminal that confirms a `y` — a non-interactive run
|
|
21
|
+
* (piped stdin, a script, CI) without `--yes` is refused rather than guessed
|
|
22
|
+
* at.
|
|
23
|
+
*/
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import readline from 'node:readline';
|
|
27
|
+
import { paths } from '../core/config.js';
|
|
28
|
+
import { readJson, writeJson } from '../core/store.js';
|
|
29
|
+
import { buildPriceBook, PRICING_SOURCES } from '../core/pricing.js';
|
|
30
|
+
import { usd, signedPct } from '../core/units.js';
|
|
31
|
+
|
|
32
|
+
const RATE_FIELDS = ['in', 'out', 'cacheRead', 'cacheWrite'];
|
|
33
|
+
const RATE_LABELS = { in: 'input', out: 'output', cacheRead: 'cache read', cacheWrite: 'cache write' };
|
|
34
|
+
|
|
35
|
+
function num(v) {
|
|
36
|
+
return v === undefined || v === null || v === '' || Number.isNaN(Number(v)) ? null : Number(v);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A model entry in the overrides-file shape, normalized to plain rate fields. */
|
|
40
|
+
function rateSpecOf(entry) {
|
|
41
|
+
if (!entry || typeof entry !== 'object') return {};
|
|
42
|
+
return {
|
|
43
|
+
in: num(entry.in ?? entry.input),
|
|
44
|
+
out: num(entry.out ?? entry.output),
|
|
45
|
+
cacheRead: num(entry.cacheRead ?? entry.cache_read),
|
|
46
|
+
cacheWrite: num(entry.cacheWrite ?? entry.cache_write),
|
|
47
|
+
cacheRefresh: num(entry.cacheRefresh ?? entry.cache_refresh),
|
|
48
|
+
match: entry.match,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Only the fields a diff cares about, out of a full `book.lookup()` entry. */
|
|
53
|
+
function pickRates(entry) {
|
|
54
|
+
return { in: entry.in, out: entry.out, cacheRead: entry.cacheRead, cacheWrite: entry.cacheWrite };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Compare a candidate price table to the current effective book.
|
|
59
|
+
*
|
|
60
|
+
* Both the current side and the candidate side are resolved through
|
|
61
|
+
* `buildPriceBook` (the same cache-rate fallback, the same "no rate" rule),
|
|
62
|
+
* so a candidate that only states `in`/`out` is compared like-for-like
|
|
63
|
+
* against a current entry that does the same — neither side is favoured by
|
|
64
|
+
* a difference in how missing cache rates are filled in.
|
|
65
|
+
*
|
|
66
|
+
* @param {{models?: Record<string, object>}} current the current overrides file contents
|
|
67
|
+
* @param {{models: Record<string, object>, sources?: string[], version?: string}} candidate
|
|
68
|
+
* @returns {{
|
|
69
|
+
* added: {model:string, to:object}[],
|
|
70
|
+
* removed: {model:string, rates:object}[],
|
|
71
|
+
* changed: {model:string, from:object, to:object, deltas:object, currentSrc:string|null, currentOrigin:string|null}[],
|
|
72
|
+
* unchanged: string[],
|
|
73
|
+
* invalid: {model:string, reason:string}[],
|
|
74
|
+
* candidateVersion: string|null,
|
|
75
|
+
* candidateSources: string[],
|
|
76
|
+
* }}
|
|
77
|
+
*/
|
|
78
|
+
export function diffPriceTables(current, candidate) {
|
|
79
|
+
if (!candidate || typeof candidate !== 'object' || typeof candidate.models !== 'object' || candidate.models === null) {
|
|
80
|
+
throw new Error('candidate must be an object with a "models" map, e.g. { "models": { "<model>": { "in": 1, "out": 2 } } }');
|
|
81
|
+
}
|
|
82
|
+
const currentModels = (current && current.models) || {};
|
|
83
|
+
const candidateModels = candidate.models;
|
|
84
|
+
|
|
85
|
+
// A candidate entry without a usable in/out rate would otherwise fall
|
|
86
|
+
// through buildPriceBook's lookup (it skips entries missing in/out) and
|
|
87
|
+
// silently read back as "unchanged" against the builtin table — the worst
|
|
88
|
+
// failure mode for a tool whose whole job is telling a human what changed.
|
|
89
|
+
const invalid = [];
|
|
90
|
+
const validCandidateModels = {};
|
|
91
|
+
for (const [key, spec] of Object.entries(candidateModels)) {
|
|
92
|
+
const r = rateSpecOf(spec);
|
|
93
|
+
if (!Number.isFinite(r.in) || !Number.isFinite(r.out)) {
|
|
94
|
+
invalid.push({ model: key, reason: 'missing or non-numeric "in"/"out" rate' });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
validCandidateModels[key] = spec;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const currentBook = buildPriceBook({ models: currentModels });
|
|
101
|
+
const candidateBook = buildPriceBook({ models: validCandidateModels });
|
|
102
|
+
|
|
103
|
+
const added = [];
|
|
104
|
+
const changed = [];
|
|
105
|
+
const unchanged = [];
|
|
106
|
+
for (const key of Object.keys(validCandidateModels)) {
|
|
107
|
+
const to = candidateBook.lookup(key, undefined);
|
|
108
|
+
if (!to) {
|
|
109
|
+
// A finite in/out passed validation above, but an explicit `match`
|
|
110
|
+
// override that does not match its own key means the lookup never
|
|
111
|
+
// finds this entry at all — the same "no usable rate" outcome as a
|
|
112
|
+
// missing in/out, just reached a different way.
|
|
113
|
+
invalid.push({ model: key, reason: '"match" pattern does not match its own model key' });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const from = currentBook.lookup(key, undefined);
|
|
117
|
+
if (!from) { added.push({ model: key, to: pickRates(to) }); continue; }
|
|
118
|
+
const deltas = {};
|
|
119
|
+
let any = false;
|
|
120
|
+
for (const f of RATE_FIELDS) {
|
|
121
|
+
const a = from[f] ?? null;
|
|
122
|
+
const b = to[f] ?? null;
|
|
123
|
+
if (a === b) continue;
|
|
124
|
+
any = true;
|
|
125
|
+
deltas[f] = { from: a, to: b, pct: (a === null || a === undefined || a === 0) ? null : (b - a) / a };
|
|
126
|
+
}
|
|
127
|
+
if (any) changed.push({ model: key, from: pickRates(from), to: pickRates(to), deltas, currentSrc: from.src ?? null, currentOrigin: from.origin ?? null });
|
|
128
|
+
else unchanged.push(key);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const removed = Object.keys(currentModels)
|
|
132
|
+
.filter((k) => !(k in candidateModels))
|
|
133
|
+
.map((k) => ({ model: k, rates: rateSpecOf(currentModels[k]) }));
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
added,
|
|
137
|
+
removed,
|
|
138
|
+
changed,
|
|
139
|
+
unchanged,
|
|
140
|
+
invalid,
|
|
141
|
+
candidateVersion: candidate.version ?? null,
|
|
142
|
+
candidateSources: Array.isArray(candidate.sources) ? candidate.sources : [],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function fmtRate(n) {
|
|
147
|
+
return n === null || n === undefined ? 'n/a' : usd(n);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function rateLine(r) {
|
|
151
|
+
const bits = [`in ${fmtRate(r.in)}`, `out ${fmtRate(r.out)}`];
|
|
152
|
+
if (r.cacheRead !== null && r.cacheRead !== undefined) bits.push(`cacheRead ${fmtRate(r.cacheRead)}`);
|
|
153
|
+
if (r.cacheWrite !== null && r.cacheWrite !== undefined) bits.push(`cacheWrite ${fmtRate(r.cacheWrite)}`);
|
|
154
|
+
return bits.join(', ');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Render a diff the way a person reviews it before deciding to `--apply`. */
|
|
158
|
+
function renderDiff(diff) {
|
|
159
|
+
const lines = [];
|
|
160
|
+
lines.push(`${diff.candidateVersion ? `candidate table version: ${diff.candidateVersion}` : 'candidate table (no version stated)'}`);
|
|
161
|
+
if (diff.invalid.length) {
|
|
162
|
+
lines.push('');
|
|
163
|
+
lines.push(`${diff.invalid.length} invalid candidate entr${diff.invalid.length === 1 ? 'y' : 'ies'} — skipped, and --apply is refused while these exist:`);
|
|
164
|
+
for (const i of diff.invalid) lines.push(` ✗ ${i.model}: ${i.reason}`);
|
|
165
|
+
}
|
|
166
|
+
if (diff.added.length) {
|
|
167
|
+
lines.push('');
|
|
168
|
+
lines.push(`${diff.added.length} added:`);
|
|
169
|
+
for (const a of diff.added) lines.push(` + ${a.model}: ${rateLine(a.to)}`);
|
|
170
|
+
}
|
|
171
|
+
if (diff.changed.length) {
|
|
172
|
+
lines.push('');
|
|
173
|
+
lines.push(`${diff.changed.length} changed:`);
|
|
174
|
+
for (const c of diff.changed) {
|
|
175
|
+
const parts = RATE_FIELDS.filter((f) => c.deltas[f]).map((f) => {
|
|
176
|
+
const d = c.deltas[f];
|
|
177
|
+
return `${RATE_LABELS[f]} ${fmtRate(d.from)} -> ${fmtRate(d.to)} (${d.pct === null ? 'n/a' : signedPct(d.pct)})`;
|
|
178
|
+
});
|
|
179
|
+
const was = c.currentSrc ? ` [current: ${c.currentOrigin === 'user' ? 'your override' : c.currentSrc}]` : '';
|
|
180
|
+
lines.push(` ~ ${c.model}: ${parts.join(', ')}${was}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (diff.removed.length) {
|
|
184
|
+
lines.push('');
|
|
185
|
+
lines.push(`${diff.removed.length} in your current overrides but not mentioned by this candidate — untouched by --apply (a merge, not a replace):`);
|
|
186
|
+
for (const r of diff.removed) lines.push(` · ${r.model}: ${rateLine(r.rates)}`);
|
|
187
|
+
}
|
|
188
|
+
if (!diff.added.length && !diff.changed.length && !diff.removed.length && !diff.invalid.length) {
|
|
189
|
+
lines.push('');
|
|
190
|
+
lines.push('no differences from the current effective price book.');
|
|
191
|
+
}
|
|
192
|
+
lines.push('');
|
|
193
|
+
lines.push(`candidate sources: ${diff.candidateSources.length ? diff.candidateSources.join('; ') : '(none stated)'}`);
|
|
194
|
+
lines.push('current built-in sources:');
|
|
195
|
+
for (const [key, src] of Object.entries(PRICING_SOURCES)) {
|
|
196
|
+
lines.push(` ${key}: ${src.confidence} — ${src.url} (fetched ${src.fetched})`);
|
|
197
|
+
}
|
|
198
|
+
return lines.join('\n');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Merge a candidate's models into the overrides file — never a replace.
|
|
203
|
+
* Any model the candidate does not mention keeps its existing override
|
|
204
|
+
* exactly as it was; a model the candidate does mention is fully replaced by
|
|
205
|
+
* the candidate's rate object for that model. Only `models` and `updatedAt`
|
|
206
|
+
* are written — a candidate's `sources`/`version` are for the diff review,
|
|
207
|
+
* not for the overrides file's own schema.
|
|
208
|
+
*
|
|
209
|
+
* @param {{candidate: {models: Record<string, object>}, overridesPath: string}} opt
|
|
210
|
+
* @returns {{path:string, applied:string[], modelCount:number}}
|
|
211
|
+
*/
|
|
212
|
+
export function applyCandidate({ candidate, overridesPath }) {
|
|
213
|
+
if (!candidate || typeof candidate.models !== 'object' || candidate.models === null) {
|
|
214
|
+
throw new Error('candidate must have a "models" map to apply');
|
|
215
|
+
}
|
|
216
|
+
const existing = readJson(overridesPath, { models: {} });
|
|
217
|
+
const mergedModels = { ...(existing.models || {}), ...candidate.models };
|
|
218
|
+
const merged = { ...existing, models: mergedModels, updatedAt: new Date().toISOString() };
|
|
219
|
+
writeJson(overridesPath, merged);
|
|
220
|
+
return { path: overridesPath, applied: Object.keys(candidate.models), modelCount: Object.keys(mergedModels).length };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function readCandidateInput(file, stdinReader) {
|
|
224
|
+
const raw = file === '-'
|
|
225
|
+
? (stdinReader ? stdinReader() : fs.readFileSync(0, 'utf8'))
|
|
226
|
+
: fs.readFileSync(path.resolve(file), 'utf8');
|
|
227
|
+
return JSON.parse(raw);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Default confirm: a real y/N prompt on the controlling terminal. */
|
|
231
|
+
function defaultConfirm(question) {
|
|
232
|
+
return new Promise((resolve) => {
|
|
233
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
234
|
+
rl.question(question, (answer) => {
|
|
235
|
+
rl.close();
|
|
236
|
+
resolve(/^y(es)?$/i.test(String(answer).trim()));
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* CLI entry for `tokenflow pricing diff`. Returns what to print and the exit
|
|
243
|
+
* code; the caller decides the stream (matches src/commands/guard.js's `run`
|
|
244
|
+
* convention).
|
|
245
|
+
*
|
|
246
|
+
* @param {{
|
|
247
|
+
* file?: string,
|
|
248
|
+
* apply?: boolean,
|
|
249
|
+
* yes?: boolean,
|
|
250
|
+
* overridesPath?: string,
|
|
251
|
+
* isTTY?: boolean,
|
|
252
|
+
* confirm?: (question:string) => Promise<boolean>,
|
|
253
|
+
* stdinReader?: () => string,
|
|
254
|
+
* write?: (s: string) => void,
|
|
255
|
+
* }} flags
|
|
256
|
+
* `overridesPath`, `isTTY`, `confirm`, `stdinReader` and `write` are
|
|
257
|
+
* injectable seams for tests; a real CLI invocation only ever sets `file`,
|
|
258
|
+
* `apply` and `yes`. `write` is only used on the interactive-confirm path,
|
|
259
|
+
* where the diff must reach the terminal before the confirm prompt does
|
|
260
|
+
* (defaults to `process.stdout.write`).
|
|
261
|
+
* @returns {Promise<{stdout: string|null, stderr: string|null, exitCode: number}>}
|
|
262
|
+
*/
|
|
263
|
+
export async function run(flags = {}) {
|
|
264
|
+
const file = flags.file;
|
|
265
|
+
if (!file) {
|
|
266
|
+
return { stdout: null, stderr: 'usage: tokenflow pricing diff <file.json|-> [--apply] [--yes]', exitCode: 1 };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let candidate;
|
|
270
|
+
try {
|
|
271
|
+
candidate = readCandidateInput(file, flags.stdinReader);
|
|
272
|
+
} catch (err) {
|
|
273
|
+
return { stdout: null, stderr: `could not read candidate pricing table: ${err.message}`, exitCode: 1 };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const overridesPath = flags.overridesPath || paths().pricing;
|
|
277
|
+
const current = readJson(overridesPath, { models: {} });
|
|
278
|
+
|
|
279
|
+
let diff;
|
|
280
|
+
try {
|
|
281
|
+
diff = diffPriceTables(current, candidate);
|
|
282
|
+
} catch (err) {
|
|
283
|
+
return { stdout: null, stderr: err.message, exitCode: 1 };
|
|
284
|
+
}
|
|
285
|
+
const text = renderDiff(diff);
|
|
286
|
+
|
|
287
|
+
if (!flags.apply) return { stdout: text, stderr: null, exitCode: 0 };
|
|
288
|
+
|
|
289
|
+
if (diff.invalid.length) {
|
|
290
|
+
return { stdout: text, stderr: `refusing to apply: ${diff.invalid.length} invalid candidate entr${diff.invalid.length === 1 ? 'y' : 'ies'}.`, exitCode: 1 };
|
|
291
|
+
}
|
|
292
|
+
const hasChanges = diff.added.length > 0 || diff.changed.length > 0;
|
|
293
|
+
if (!hasChanges) {
|
|
294
|
+
return { stdout: `${text}\n\nnothing to apply — the candidate matches the current effective table.`, stderr: null, exitCode: 0 };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (!flags.yes) {
|
|
298
|
+
const isTTY = flags.isTTY !== undefined ? flags.isTTY : !!(process.stdin && process.stdin.isTTY);
|
|
299
|
+
if (!isTTY) {
|
|
300
|
+
return { stdout: text, stderr: 'refusing to apply non-interactively — re-run with --yes to confirm.', exitCode: 1 };
|
|
301
|
+
}
|
|
302
|
+
// On a real terminal the confirm prompt (readline, or an injected mock)
|
|
303
|
+
// writes straight to stdout the moment it is called — so the diff must
|
|
304
|
+
// already be on screen before that happens, not bundled into a return
|
|
305
|
+
// value the caller would only print afterwards.
|
|
306
|
+
(flags.write || ((s) => process.stdout.write(s)))(`${text}\n\n`);
|
|
307
|
+
const confirmFn = flags.confirm || defaultConfirm;
|
|
308
|
+
const ok = await confirmFn(`Apply ${diff.added.length} added / ${diff.changed.length} changed model rate(s) to ${overridesPath}? [y/N] `);
|
|
309
|
+
if (!ok) return { stdout: 'apply cancelled.', stderr: null, exitCode: 0 };
|
|
310
|
+
const res = applyCandidate({ candidate, overridesPath });
|
|
311
|
+
return { stdout: `✓ applied ${res.applied.length} model rate(s) to ${res.path}`, stderr: null, exitCode: 0 };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const res = applyCandidate({ candidate, overridesPath });
|
|
315
|
+
return { stdout: `${text}\n\n✓ applied ${res.applied.length} model rate(s) to ${res.path}`, stderr: null, exitCode: 0 };
|
|
316
|
+
}
|