faberwright 0.4.0 → 0.4.1
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 +62 -48
- package/dist/agent.js +11 -0
- package/dist/index.js +32 -5
- package/dist/llm.js +268 -6
- package/dist/models.js +111 -1
- package/dist/onboard.js +126 -16
- package/dist/pricing.js +20 -2
- package/dist/prompt.js +86 -26
- package/dist/usage.js +127 -82
- package/package.json +1 -1
package/dist/pricing.js
CHANGED
|
@@ -50,6 +50,14 @@ export function normalizeModelId(id) {
|
|
|
50
50
|
function cacheFile() {
|
|
51
51
|
return path.join(os.homedir(), ".faber", "cache", "prices.json");
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Bumped whenever the cached shape gains a field Faber relies on. A cache
|
|
55
|
+
* written by an older version is discarded rather than trusted: after adding
|
|
56
|
+
* `mode` and `supported_endpoints`, a stale file looked complete but carried
|
|
57
|
+
* neither, so Responses-only models were routed to the wrong endpoint and
|
|
58
|
+
* failed with a 404 that looked like a Faber bug.
|
|
59
|
+
*/
|
|
60
|
+
export const PRICE_CACHE_VERSION = 2;
|
|
53
61
|
/** Wait this long before retrying after a failed refresh. */
|
|
54
62
|
export const RETRY_AFTER_FAILURE_MS = 24 * 60 * 60 * 1000;
|
|
55
63
|
/** How old the cached prices are, in days. undefined = never fetched. */
|
|
@@ -66,7 +74,11 @@ export function pricesAreStale(now = Date.now()) {
|
|
|
66
74
|
export function readPriceCache() {
|
|
67
75
|
try {
|
|
68
76
|
const raw = JSON.parse(fs.readFileSync(cacheFile(), "utf8"));
|
|
69
|
-
|
|
77
|
+
if (!raw.prices || typeof raw.prices !== "object")
|
|
78
|
+
return undefined;
|
|
79
|
+
if ((raw.version ?? 1) < PRICE_CACHE_VERSION)
|
|
80
|
+
return undefined; // stale shape
|
|
81
|
+
return raw;
|
|
70
82
|
}
|
|
71
83
|
catch {
|
|
72
84
|
return undefined;
|
|
@@ -77,7 +89,7 @@ export function writePriceCache(prices, source, etag) {
|
|
|
77
89
|
const f = cacheFile();
|
|
78
90
|
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
79
91
|
const now = Date.now();
|
|
80
|
-
fs.writeFileSync(f, JSON.stringify({ fetchedAt: now, lastAttempt: now, source, etag, prices }, null, 2));
|
|
92
|
+
fs.writeFileSync(f, JSON.stringify({ version: PRICE_CACHE_VERSION, fetchedAt: now, lastAttempt: now, source, etag, prices }, null, 2));
|
|
81
93
|
}
|
|
82
94
|
catch { /* best effort */ }
|
|
83
95
|
}
|
|
@@ -103,6 +115,7 @@ export function markRefreshAttempt(now = Date.now()) {
|
|
|
103
115
|
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
104
116
|
const existing = readPriceCache();
|
|
105
117
|
fs.writeFileSync(f, JSON.stringify({
|
|
118
|
+
version: PRICE_CACHE_VERSION,
|
|
106
119
|
fetchedAt: existing?.fetchedAt ?? 0,
|
|
107
120
|
lastAttempt: now,
|
|
108
121
|
source: existing?.source ?? "none",
|
|
@@ -214,6 +227,11 @@ export async function refreshPrices(url = DATASET_URL, opts = {}) {
|
|
|
214
227
|
// and these values get written to a file people read.
|
|
215
228
|
const perM = (n) => Math.round(n * 1e6 * 1e6) / 1e6;
|
|
216
229
|
const price = { in: perM(inC), out: perM(outC) };
|
|
230
|
+
if (typeof v["mode"] === "string")
|
|
231
|
+
price.mode = v["mode"];
|
|
232
|
+
const eps = v["supported_endpoints"];
|
|
233
|
+
if (Array.isArray(eps))
|
|
234
|
+
price.endpoints = eps.filter((e) => typeof e === "string");
|
|
217
235
|
const cr = v["cache_read_input_token_cost"], cw = v["cache_creation_input_token_cost"];
|
|
218
236
|
if (typeof cr === "number")
|
|
219
237
|
price.cacheRead = perM(cr);
|
package/dist/prompt.js
CHANGED
|
@@ -17,17 +17,48 @@ async function selectRaw(rl, question, options, defaultIndex = 0) {
|
|
|
17
17
|
const n = Number.parseInt(ans, 10);
|
|
18
18
|
return Number.isInteger(n) && n >= 1 && n <= options.length ? n - 1 : defaultIndex;
|
|
19
19
|
}
|
|
20
|
-
|
|
20
|
+
// Long lists (a provider can return dozens of models) are unusable with
|
|
21
|
+
// arrow keys alone, so typing filters the list as you go.
|
|
22
|
+
const searchable = options.length > 8;
|
|
23
|
+
console.log(pc.bold(question) +
|
|
24
|
+
pc.dim(searchable ? " ↑/↓ then Enter · type to filter" : " ↑/↓ then Enter, or 1-9"));
|
|
21
25
|
return new Promise((resolve) => {
|
|
22
|
-
let
|
|
23
|
-
let
|
|
26
|
+
let filter = "";
|
|
27
|
+
let view = options.map((_, i) => i); // indices currently shown
|
|
28
|
+
let cursor = Math.max(0, view.indexOf(defaultIndex));
|
|
29
|
+
let painted = 0; // rows drawn last time
|
|
30
|
+
const applyFilter = () => {
|
|
31
|
+
const q = filter.toLowerCase();
|
|
32
|
+
const next = options
|
|
33
|
+
.map((o, i) => [o, i])
|
|
34
|
+
.filter(([o]) => o.toLowerCase().includes(q))
|
|
35
|
+
.map(([, i]) => i);
|
|
36
|
+
view = next.length ? next : [];
|
|
37
|
+
cursor = 0;
|
|
38
|
+
};
|
|
24
39
|
const render = () => {
|
|
25
|
-
if (
|
|
26
|
-
process.stdout.write(`\x1b[${
|
|
27
|
-
|
|
28
|
-
|
|
40
|
+
if (painted)
|
|
41
|
+
process.stdout.write(`\x1b[${painted}A`);
|
|
42
|
+
const rows = view.length ? view.length : 1;
|
|
43
|
+
const extra = filter ? 1 : 0;
|
|
44
|
+
for (let r = 0; r < view.length; r++) {
|
|
29
45
|
process.stdout.write("\x1b[2K");
|
|
30
|
-
|
|
46
|
+
const i = view[r];
|
|
47
|
+
process.stdout.write((r === cursor ? pc.cyan(`❯ ${options[i]}`) : pc.dim(` ${options[i]}`)) + "\n");
|
|
48
|
+
}
|
|
49
|
+
if (!view.length) {
|
|
50
|
+
process.stdout.write("\x1b[2K" + pc.yellow(` no match for "${filter}"`) + "\n");
|
|
51
|
+
}
|
|
52
|
+
if (filter) {
|
|
53
|
+
process.stdout.write("\x1b[2K" + pc.dim(` filter: ${filter}`) + "\n");
|
|
54
|
+
}
|
|
55
|
+
// clear any rows the previous, longer render left behind
|
|
56
|
+
for (let r = rows + extra; r < painted; r++)
|
|
57
|
+
process.stdout.write("\x1b[2K\n");
|
|
58
|
+
painted = Math.max(rows + extra, painted);
|
|
59
|
+
if (painted > rows + extra) {
|
|
60
|
+
process.stdout.write(`\x1b[${painted - (rows + extra)}A`);
|
|
61
|
+
painted = rows + extra;
|
|
31
62
|
}
|
|
32
63
|
};
|
|
33
64
|
const stdin = process.stdin;
|
|
@@ -58,24 +89,45 @@ async function selectRaw(rl, question, options, defaultIndex = 0) {
|
|
|
58
89
|
key = s[i];
|
|
59
90
|
i += 1;
|
|
60
91
|
}
|
|
61
|
-
if (key === "\x1b[A"
|
|
62
|
-
|
|
92
|
+
if (key === "\x1b[A") {
|
|
93
|
+
if (view.length)
|
|
94
|
+
cursor = (cursor - 1 + view.length) % view.length;
|
|
63
95
|
render();
|
|
64
96
|
}
|
|
65
|
-
else if (key === "\x1b[B"
|
|
66
|
-
|
|
97
|
+
else if (key === "\x1b[B") {
|
|
98
|
+
if (view.length)
|
|
99
|
+
cursor = (cursor + 1) % view.length;
|
|
67
100
|
render();
|
|
68
101
|
}
|
|
69
|
-
else if (key >= "1" && key <= "9" && Number(key) <= options.length) {
|
|
70
|
-
idx = Number(key) - 1;
|
|
71
|
-
render();
|
|
72
|
-
finish(idx);
|
|
73
|
-
done = true;
|
|
74
|
-
}
|
|
75
102
|
else if (key === "\r" || key === "\n") {
|
|
76
|
-
|
|
103
|
+
if (view.length) {
|
|
104
|
+
finish(view[cursor]);
|
|
105
|
+
done = true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
else if (key === "\x7f" || key === "\b") { // backspace edits the filter
|
|
109
|
+
if (filter) {
|
|
110
|
+
filter = filter.slice(0, -1);
|
|
111
|
+
applyFilter();
|
|
112
|
+
render();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Number shortcuts only while unfiltered; once you're typing, digits
|
|
116
|
+
// are part of the search term (model ids are full of them).
|
|
117
|
+
else if (!filter && !searchable && key >= "1" && key <= "9" && Number(key) <= options.length) {
|
|
118
|
+
finish(Number(key) - 1);
|
|
77
119
|
done = true;
|
|
78
120
|
}
|
|
121
|
+
else if (searchable && key >= " " && key !== "\x1b") {
|
|
122
|
+
filter += key;
|
|
123
|
+
applyFilter();
|
|
124
|
+
render();
|
|
125
|
+
}
|
|
126
|
+
else if (!searchable && (key === "k" || key === "j")) {
|
|
127
|
+
if (view.length)
|
|
128
|
+
cursor = (cursor + (key === "k" ? -1 : 1) + view.length) % view.length;
|
|
129
|
+
render();
|
|
130
|
+
}
|
|
79
131
|
// Ctrl-C or Esc cancels. Returning -1 used to leak out as an array
|
|
80
132
|
// index, crashing the caller with "cannot read properties of
|
|
81
133
|
// undefined" — a cancel must be a clean exit, not a bad index.
|
|
@@ -118,7 +170,16 @@ export async function readSecret(rl, promptText, io) {
|
|
|
118
170
|
resolve(value.trim());
|
|
119
171
|
};
|
|
120
172
|
const onData = (buf) => {
|
|
121
|
-
|
|
173
|
+
// Terminals wrap pasted text in bracketed-paste markers, ESC[200~ before
|
|
174
|
+
// and ESC[201~ after. The ESC byte is below space and gets dropped by the
|
|
175
|
+
// printable test below, but "[200~" is ordinary text and would be glued
|
|
176
|
+
// onto the secret — which is how a pasted key ends up rejected as
|
|
177
|
+
// malformed. Strip the markers, and any other escape sequence, first.
|
|
178
|
+
const chunk = buf.toString("utf8")
|
|
179
|
+
.replace(/\x1b\[20[01]~/g, "")
|
|
180
|
+
.replace(/\x1b\[[0-9;]*[A-Za-z~]/g, "")
|
|
181
|
+
.replace(/\x1b./g, "");
|
|
182
|
+
for (const ch of chunk) {
|
|
122
183
|
if (ch === "\r" || ch === "\n")
|
|
123
184
|
return done();
|
|
124
185
|
if (ch === "\x03") {
|
|
@@ -126,16 +187,15 @@ export async function readSecret(rl, promptText, io) {
|
|
|
126
187
|
return done();
|
|
127
188
|
} // Ctrl-C
|
|
128
189
|
if (ch === "\x7f" || ch === "\b") { // backspace
|
|
129
|
-
if (value.length)
|
|
190
|
+
if (value.length)
|
|
130
191
|
value = value.slice(0, -1);
|
|
131
|
-
stdout.write("\b \b");
|
|
132
|
-
}
|
|
133
192
|
continue;
|
|
134
193
|
}
|
|
135
|
-
|
|
194
|
+
// Echo nothing at all, the way sudo and ssh do. Masking characters
|
|
195
|
+
// would still reveal the key's length, and a hundred dots for a long
|
|
196
|
+
// key looks like something went wrong.
|
|
197
|
+
if (ch >= " ")
|
|
136
198
|
value += ch;
|
|
137
|
-
stdout.write("•");
|
|
138
|
-
}
|
|
139
199
|
}
|
|
140
200
|
};
|
|
141
201
|
stdin.on("data", onData);
|
package/dist/usage.js
CHANGED
|
@@ -9,7 +9,7 @@ import * as fs from "node:fs";
|
|
|
9
9
|
import * as os from "node:os";
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import { DatabaseSync } from "node:sqlite";
|
|
12
|
-
import { priceFor, cacheReadPrice, cacheWritePrice, PRICES_AS_OF, readPriceCache
|
|
12
|
+
import { priceFor, cacheReadPrice, cacheWritePrice, PRICES_AS_OF, readPriceCache } from "./pricing.js";
|
|
13
13
|
export class UsageLedger {
|
|
14
14
|
projectPath;
|
|
15
15
|
db;
|
|
@@ -34,6 +34,11 @@ export class UsageLedger {
|
|
|
34
34
|
}
|
|
35
35
|
catch { /* already migrated */ }
|
|
36
36
|
}
|
|
37
|
+
// Seven time windows each filter on ts, so make that lookup cheap.
|
|
38
|
+
try {
|
|
39
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_ts ON tasks(ts)");
|
|
40
|
+
}
|
|
41
|
+
catch { /* fine */ }
|
|
37
42
|
this.registerProject();
|
|
38
43
|
}
|
|
39
44
|
/**
|
|
@@ -146,100 +151,140 @@ export class UsageLedger {
|
|
|
146
151
|
const k = (n) => n >= 1_000_000 ? (n / 1_000_000).toFixed(1) + "M"
|
|
147
152
|
: n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n);
|
|
148
153
|
const money = (v) => v === undefined ? "—" : `$${v.toFixed(2)}`;
|
|
154
|
+
/** Cents below a dollar — 3.4¢ reads better than $0.034. */
|
|
155
|
+
const cents = (usd) => usd < 1 ? `${(usd * 100).toFixed(1)}¢` : `$${usd.toFixed(2)}`;
|
|
156
|
+
/** "gpt-5.3-codex", not a 40-character regional deployment id. */
|
|
157
|
+
function shortModel(id) {
|
|
158
|
+
return id
|
|
159
|
+
.replace(/^(global|us|eu|apac|au|jp)\./, "")
|
|
160
|
+
.replace(/^anthropic\./, "")
|
|
161
|
+
.replace(/-v\d+:\d+$/, "")
|
|
162
|
+
// "claude-haiku-4-5-20251001" -> "haiku-4-5": the vendor is already known
|
|
163
|
+
// from the route, and a mid-word truncation reads as a different model.
|
|
164
|
+
.replace(/^claude-/, "")
|
|
165
|
+
.replace(/-\d{8}$/, "")
|
|
166
|
+
.slice(0, 15);
|
|
167
|
+
}
|
|
168
|
+
const DAY = 86_400_000;
|
|
149
169
|
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
170
|
+
* Rolling windows, not calendar ones.
|
|
171
|
+
*
|
|
172
|
+
* "This month" means one day on the 1st and thirty on the 31st, and it resets
|
|
173
|
+
* overnight — two figures side by side aren't comparable. Rolling windows
|
|
174
|
+
* always cover the span they name.
|
|
152
175
|
*/
|
|
153
|
-
|
|
154
|
-
const midnight = new Date();
|
|
176
|
+
function windows(now) {
|
|
177
|
+
const midnight = new Date(now);
|
|
155
178
|
midnight.setHours(0, 0, 0, 0);
|
|
156
|
-
|
|
157
|
-
["this session", undefined],
|
|
179
|
+
return [
|
|
158
180
|
["today", midnight.getTime()],
|
|
181
|
+
["last 7 days", now - 7 * DAY],
|
|
182
|
+
["last 14 days", now - 14 * DAY],
|
|
183
|
+
["last 30 days", now - 30 * DAY],
|
|
184
|
+
["last 3 months", now - 91 * DAY],
|
|
185
|
+
["last 6 months", now - 182 * DAY],
|
|
159
186
|
["all time", 0],
|
|
160
187
|
];
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
let
|
|
166
|
-
let
|
|
188
|
+
}
|
|
189
|
+
/** Sum a set of per-model bands into one row, pricing anything unrecorded. */
|
|
190
|
+
function rollUp(bands, override) {
|
|
191
|
+
const totals = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, calls: 0, tasks: 0 };
|
|
192
|
+
let cost;
|
|
193
|
+
let saved;
|
|
194
|
+
let unpriced = 0;
|
|
167
195
|
let estimated = false;
|
|
168
|
-
for (const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
saved = (saved ?? 0) + b.billed.saved;
|
|
190
|
-
if (b.billed.unpriced > 0) {
|
|
191
|
-
// Rows recorded before costs were stored have nothing to preserve, so
|
|
192
|
-
// estimate them at today's prices and flag the row as an estimate.
|
|
193
|
-
legacyRows += b.billed.unpriced;
|
|
194
|
-
const p = priceFor(b.model, override);
|
|
195
|
-
const c = UsageLedger.cost(b.totals, p);
|
|
196
|
-
const s = UsageLedger.saved(b.totals, p);
|
|
197
|
-
if (c !== undefined) {
|
|
198
|
-
cost = (cost ?? 0) + c;
|
|
199
|
-
anyPriced = true;
|
|
200
|
-
estimated = true;
|
|
201
|
-
}
|
|
202
|
-
if (s !== undefined)
|
|
203
|
-
saved = (saved ?? 0) + s;
|
|
196
|
+
for (const b of bands) {
|
|
197
|
+
totals.input += b.totals.input;
|
|
198
|
+
totals.cacheRead += b.totals.cacheRead;
|
|
199
|
+
totals.cacheWrite += b.totals.cacheWrite;
|
|
200
|
+
totals.output += b.totals.output;
|
|
201
|
+
totals.calls += b.totals.calls;
|
|
202
|
+
totals.tasks += b.totals.tasks;
|
|
203
|
+
if (b.billed.cost !== null)
|
|
204
|
+
cost = (cost ?? 0) + b.billed.cost;
|
|
205
|
+
if (b.billed.saved !== null)
|
|
206
|
+
saved = (saved ?? 0) + b.billed.saved;
|
|
207
|
+
if (b.billed.unpriced > 0) {
|
|
208
|
+
// Tasks recorded before costs were stored have nothing to preserve, so
|
|
209
|
+
// estimate them at today's rates rather than showing a gap.
|
|
210
|
+
unpriced += b.billed.unpriced;
|
|
211
|
+
const p = priceFor(b.model, override);
|
|
212
|
+
const c = UsageLedger.cost(b.totals, p);
|
|
213
|
+
const s = UsageLedger.saved(b.totals, p);
|
|
214
|
+
if (c !== undefined) {
|
|
215
|
+
cost = (cost ?? 0) + c;
|
|
216
|
+
estimated = true;
|
|
204
217
|
}
|
|
218
|
+
if (s !== undefined)
|
|
219
|
+
saved = (saved ?? 0) + s;
|
|
205
220
|
}
|
|
206
|
-
const totalIn = agg.input + agg.cacheRead + agg.cacheWrite;
|
|
207
|
-
const pct = totalIn > 0 ? Math.round((agg.cacheRead / totalIn) * 100) + "%" : "0%";
|
|
208
|
-
lines.push(cells([label, String(agg.tasks), k(totalIn), pct, k(agg.output),
|
|
209
|
-
money(cost), money(saved)]));
|
|
210
221
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
222
|
+
return { totals, cost, saved, unpriced, estimated };
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* The /usage panel for this project.
|
|
226
|
+
*
|
|
227
|
+
* Seven fixed windows, never collapsed even when identical: a missing row
|
|
228
|
+
* would read as "no data" when it actually means "nothing new since", and
|
|
229
|
+
* that distinction is exactly what someone returning after a break wants.
|
|
230
|
+
*
|
|
231
|
+
* The two columns nobody can interpret unaided — cached and saved — are
|
|
232
|
+
* defined underneath. A metric a reader has to guess at gets ignored.
|
|
233
|
+
*/
|
|
234
|
+
export function renderUsagePanel(ledger, override, now = Date.now()) {
|
|
235
|
+
const W = [15, 7, 9, 8, 7, 8, 9];
|
|
236
|
+
const cell = (c) => c.map((s, i) => (i === c.length - 1 ? s : s.padEnd(W[i]))).join(" ").trimEnd();
|
|
237
|
+
const pad = " ".repeat(W[0]);
|
|
238
|
+
const lines = [path.basename(ledger.projectPath), ""];
|
|
239
|
+
lines.push(cell(["", "tasks", "in", "cached", "out", "cost", "saved"]));
|
|
240
|
+
let allTime = rollUp([], override);
|
|
241
|
+
for (const [label, since] of windows(now)) {
|
|
242
|
+
const r = rollUp(ledger.totalsByModel(since), override);
|
|
243
|
+
const totalIn = r.totals.input + r.totals.cacheRead + r.totals.cacheWrite;
|
|
244
|
+
lines.push(cell([
|
|
245
|
+
label, String(r.totals.tasks), k(totalIn),
|
|
246
|
+
totalIn > 0 ? `${Math.round((r.totals.cacheRead / totalIn) * 100)}%` : "0%",
|
|
247
|
+
k(r.totals.output), money(r.cost), money(r.saved),
|
|
248
|
+
]));
|
|
249
|
+
if (label === "all time")
|
|
250
|
+
allTime = r;
|
|
228
251
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
lines.push(
|
|
252
|
+
lines.push("");
|
|
253
|
+
if (allTime.cost !== undefined && allTime.totals.tasks > 0) {
|
|
254
|
+
lines.push(pad + `${cents(allTime.cost / allTime.totals.tasks)} per task`);
|
|
255
|
+
}
|
|
256
|
+
lines.push(pad + "cached = input reused from cache, billed at ~10%");
|
|
257
|
+
if (allTime.cost !== undefined && allTime.saved !== undefined && allTime.saved > 0) {
|
|
258
|
+
lines.push(pad +
|
|
259
|
+
`saved = caching cost you ${money(allTime.cost)} instead of ${money(allTime.cost + allTime.saved)}`);
|
|
260
|
+
}
|
|
261
|
+
const src = readPriceCache();
|
|
262
|
+
lines.push(pad + (src
|
|
263
|
+
? `rates ${new Date(src.fetchedAt).toISOString().slice(0, 10)} · /usage --refresh-prices`
|
|
264
|
+
: `built-in rates, as of ${PRICES_AS_OF} · /usage --refresh-prices`));
|
|
265
|
+
if (allTime.unpriced > 0) {
|
|
266
|
+
lines.push(pad +
|
|
267
|
+
`${allTime.unpriced} older task(s) predate cost tracking${allTime.estimated ? ", estimated" : ""}`);
|
|
232
268
|
}
|
|
233
|
-
|
|
234
|
-
|
|
269
|
+
// Where the money goes. Only models actually used appear, because this is
|
|
270
|
+
// built from recorded tasks rather than a catalogue.
|
|
271
|
+
const byModel = ledger.totalsByModel(0)
|
|
272
|
+
.filter((b) => b.totals.tasks > 0)
|
|
273
|
+
.map((b) => {
|
|
274
|
+
const r = rollUp([b], override);
|
|
275
|
+
return { model: b.model, totals: r.totals, cost: r.cost };
|
|
276
|
+
})
|
|
277
|
+
.sort((a, b) => (b.cost ?? 0) - (a.cost ?? 0));
|
|
278
|
+
if (byModel.length > 1) {
|
|
235
279
|
lines.push("");
|
|
236
|
-
lines.push("
|
|
237
|
-
for (const
|
|
238
|
-
const totalIn =
|
|
239
|
-
lines.push(
|
|
240
|
-
|
|
241
|
-
totalIn > 0 ? Math.round((
|
|
242
|
-
k(
|
|
280
|
+
lines.push(cell(["by model", "tasks", "in", "cached", "out", "cost", "per task"]));
|
|
281
|
+
for (const b of byModel) {
|
|
282
|
+
const totalIn = b.totals.input + b.totals.cacheRead + b.totals.cacheWrite;
|
|
283
|
+
lines.push(cell([
|
|
284
|
+
shortModel(b.model), String(b.totals.tasks), k(totalIn),
|
|
285
|
+
totalIn > 0 ? `${Math.round((b.totals.cacheRead / totalIn) * 100)}%` : "0%",
|
|
286
|
+
k(b.totals.output), money(b.cost),
|
|
287
|
+
b.cost !== undefined ? cents(b.cost / b.totals.tasks) : "—",
|
|
243
288
|
]));
|
|
244
289
|
}
|
|
245
290
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberwright",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Faber: an agentic AI coding assistant for your terminal — streams, edits with diff approval, runs your tests, and remembers your project across sessions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|