faberwright 0.3.1 → 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 +183 -55
- package/dist/agent.js +20 -1
- package/dist/config.js +48 -5
- package/dist/credentials.js +134 -0
- package/dist/index.js +252 -9
- package/dist/llm.js +344 -6
- package/dist/models.js +180 -0
- package/dist/onboard.js +364 -0
- package/dist/pricing.js +272 -0
- package/dist/prompt.js +158 -21
- package/dist/routes.js +143 -0
- package/dist/settings.js +55 -0
- package/dist/sigv4.js +177 -0
- package/dist/usage.js +187 -42
- package/package.json +1 -1
package/dist/usage.js
CHANGED
|
@@ -9,10 +9,15 @@ 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 } from "./pricing.js";
|
|
12
13
|
export class UsageLedger {
|
|
13
14
|
projectPath;
|
|
14
15
|
db;
|
|
15
16
|
session = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, calls: 0, tasks: 0 };
|
|
17
|
+
/** Model used by the most recent task. */
|
|
18
|
+
currentModel = "";
|
|
19
|
+
/** When this process started, so the session row can be priced per model. */
|
|
20
|
+
sessionStart = Date.now();
|
|
16
21
|
constructor(dbPath, projectPath) {
|
|
17
22
|
this.projectPath = projectPath;
|
|
18
23
|
this.db = new DatabaseSync(dbPath);
|
|
@@ -20,11 +25,33 @@ export class UsageLedger {
|
|
|
20
25
|
this.db.exec(`CREATE TABLE IF NOT EXISTS tasks (
|
|
21
26
|
ts INTEGER, input INTEGER, cache_read INTEGER, cache_write INTEGER,
|
|
22
27
|
output INTEGER, calls INTEGER, model TEXT)`);
|
|
28
|
+
// Cost is recorded when the task runs, at the prices in effect then.
|
|
29
|
+
// Prices change; money already spent does not. Ledgers created before this
|
|
30
|
+
// column existed keep NULL and are priced at display time as a fallback.
|
|
31
|
+
for (const col of ["cost REAL", "saved REAL"]) {
|
|
32
|
+
try {
|
|
33
|
+
this.db.exec(`ALTER TABLE tasks ADD COLUMN ${col}`);
|
|
34
|
+
}
|
|
35
|
+
catch { /* already migrated */ }
|
|
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 */ }
|
|
23
42
|
this.registerProject();
|
|
24
43
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Record a completed task. Cost is computed and stored NOW, using the prices
|
|
46
|
+
* in effect at this moment — later price changes never rewrite it.
|
|
47
|
+
*/
|
|
48
|
+
record(u, model, override) {
|
|
49
|
+
this.currentModel = model;
|
|
50
|
+
const p = priceFor(model, override);
|
|
51
|
+
const cost = UsageLedger.cost(u, p) ?? null;
|
|
52
|
+
const saved = UsageLedger.saved(u, p) ?? null;
|
|
53
|
+
this.db.prepare("INSERT INTO tasks VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
54
|
+
.run(Date.now(), u.input, u.cacheRead, u.cacheWrite, u.output, u.calls, model, cost, saved);
|
|
28
55
|
this.session.input += u.input;
|
|
29
56
|
this.session.cacheRead += u.cacheRead;
|
|
30
57
|
this.session.cacheWrite += u.cacheWrite;
|
|
@@ -38,18 +65,38 @@ export class UsageLedger {
|
|
|
38
65
|
COALESCE(SUM(calls),0) calls
|
|
39
66
|
FROM tasks WHERE ts >= ?`).get(sinceMs ?? 0);
|
|
40
67
|
}
|
|
41
|
-
/** Cost in USD
|
|
42
|
-
static cost(u,
|
|
43
|
-
if (!
|
|
68
|
+
/** Cost in USD for one usage record at a given model's prices. */
|
|
69
|
+
static cost(u, p) {
|
|
70
|
+
if (!p)
|
|
44
71
|
return undefined;
|
|
45
|
-
return (u.input *
|
|
46
|
-
u.cacheWrite *
|
|
72
|
+
return (u.input * p.in + u.cacheRead * cacheReadPrice(p) +
|
|
73
|
+
u.cacheWrite * cacheWritePrice(p) + u.output * p.out) / 1e6;
|
|
47
74
|
}
|
|
48
|
-
/**
|
|
49
|
-
static saved(u,
|
|
50
|
-
if (!
|
|
75
|
+
/** What caching saved vs paying full input price, net of the write premium. */
|
|
76
|
+
static saved(u, p) {
|
|
77
|
+
if (!p)
|
|
51
78
|
return undefined;
|
|
52
|
-
|
|
79
|
+
const readSaving = u.cacheRead * (p.in - cacheReadPrice(p));
|
|
80
|
+
const writePremium = u.cacheWrite * (cacheWritePrice(p) - p.in);
|
|
81
|
+
return (readSaving - writePremium) / 1e6;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Totals split by model, so a history spanning several models is priced with
|
|
85
|
+
* each model's own rates rather than whatever is loaded right now.
|
|
86
|
+
*/
|
|
87
|
+
get startedAt() { return this.sessionStart; }
|
|
88
|
+
totalsByModel(sinceMs) {
|
|
89
|
+
const rows = this.db.prepare(`SELECT model, COUNT(*) tasks, COALESCE(SUM(input),0) input,
|
|
90
|
+
COALESCE(SUM(cache_read),0) cacheRead, COALESCE(SUM(cache_write),0) cacheWrite,
|
|
91
|
+
COALESCE(SUM(output),0) output, COALESCE(SUM(calls),0) calls,
|
|
92
|
+
SUM(cost) billedCost, SUM(saved) billedSaved,
|
|
93
|
+
SUM(CASE WHEN cost IS NULL THEN 1 ELSE 0 END) unpriced
|
|
94
|
+
FROM tasks WHERE ts >= ? GROUP BY model`).all(sinceMs ?? 0);
|
|
95
|
+
return rows.map((r) => ({
|
|
96
|
+
model: r.model,
|
|
97
|
+
totals: r,
|
|
98
|
+
billed: { cost: r.billedCost, saved: r.billedSaved, unpriced: r.unpriced },
|
|
99
|
+
}));
|
|
53
100
|
}
|
|
54
101
|
// ---- global registry so usage can be summed across every project ----
|
|
55
102
|
registerProject() {
|
|
@@ -104,42 +151,140 @@ export class UsageLedger {
|
|
|
104
151
|
const k = (n) => n >= 1_000_000 ? (n / 1_000_000).toFixed(1) + "M"
|
|
105
152
|
: n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n);
|
|
106
153
|
const money = (v) => v === undefined ? "—" : `$${v.toFixed(2)}`;
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
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;
|
|
169
|
+
/**
|
|
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.
|
|
175
|
+
*/
|
|
176
|
+
function windows(now) {
|
|
177
|
+
const midnight = new Date(now);
|
|
110
178
|
midnight.setHours(0, 0, 0, 0);
|
|
111
|
-
|
|
112
|
-
["
|
|
113
|
-
["
|
|
114
|
-
["
|
|
179
|
+
return [
|
|
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],
|
|
186
|
+
["all time", 0],
|
|
115
187
|
];
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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;
|
|
195
|
+
let estimated = false;
|
|
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;
|
|
217
|
+
}
|
|
218
|
+
if (s !== undefined)
|
|
219
|
+
saved = (saved ?? 0) + s;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
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),
|
|
127
248
|
]));
|
|
249
|
+
if (label === "all time")
|
|
250
|
+
allTime = r;
|
|
128
251
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
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" : ""}`);
|
|
132
268
|
}
|
|
133
|
-
|
|
134
|
-
|
|
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) {
|
|
135
279
|
lines.push("");
|
|
136
|
-
lines.push("
|
|
137
|
-
for (const
|
|
138
|
-
const totalIn =
|
|
139
|
-
lines.push(
|
|
140
|
-
|
|
141
|
-
totalIn > 0 ? Math.round((
|
|
142
|
-
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) : "—",
|
|
143
288
|
]));
|
|
144
289
|
}
|
|
145
290
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberwright",
|
|
3
|
-
"version": "0.
|
|
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",
|