faberwright 0.3.0 → 0.4.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 +136 -22
- package/dist/agent.js +9 -1
- package/dist/config.js +48 -5
- package/dist/credentials.js +134 -0
- package/dist/index.js +225 -9
- package/dist/llm.js +79 -3
- package/dist/models.js +70 -0
- package/dist/onboard.js +254 -0
- package/dist/pricing.js +254 -0
- package/dist/prompt.js +79 -2
- package/dist/routes.js +143 -0
- package/dist/settings.js +55 -0
- package/dist/sigv4.js +177 -0
- package/dist/usage.js +129 -29
- package/package.json +10 -2
package/dist/routes.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendors, routes, and model aliases.
|
|
3
|
+
*
|
|
4
|
+
* Three levels, chosen in order:
|
|
5
|
+
* vendor — who makes the model (Anthropic, OpenAI-compatible)
|
|
6
|
+
* route — how you reach it and who owns authentication
|
|
7
|
+
* (direct API key, a cloud provider, a local server, a gateway)
|
|
8
|
+
* model — an alias (sonnet/opus/haiku) or a raw model id
|
|
9
|
+
*
|
|
10
|
+
* Aliases exist because the same model has different ids on different routes:
|
|
11
|
+
* the direct API calls it "claude-sonnet-5" while Bedrock wants a long
|
|
12
|
+
* region-prefixed inference-profile id. Aliases stay stable; the mapping moves.
|
|
13
|
+
* For cloud routes the mapping is USER-SUPPLIED — we never guess ids we can't
|
|
14
|
+
* verify, since a wrong one fails at request time with a confusing error.
|
|
15
|
+
*/
|
|
16
|
+
export const ROUTES = [
|
|
17
|
+
{
|
|
18
|
+
id: "anthropic-api",
|
|
19
|
+
vendor: "Anthropic",
|
|
20
|
+
label: "Anthropic API",
|
|
21
|
+
hint: "direct, pay per token with your own key",
|
|
22
|
+
wire: "anthropic",
|
|
23
|
+
baseUrl: "https://api.anthropic.com",
|
|
24
|
+
keyEnv: "ANTHROPIC_API_KEY",
|
|
25
|
+
implemented: true,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
// AWS's bedrock-mantle endpoint speaks the first-party Messages dialect and
|
|
29
|
+
// authenticates with a Bedrock API key, so no SigV4 signing is needed —
|
|
30
|
+
// only the base URL and the credential differ from the direct route.
|
|
31
|
+
id: "bedrock",
|
|
32
|
+
vendor: "Anthropic",
|
|
33
|
+
label: "Amazon Bedrock",
|
|
34
|
+
hint: "your AWS account owns auth and billing",
|
|
35
|
+
wire: "anthropic",
|
|
36
|
+
needsRegion: true,
|
|
37
|
+
baseUrlTemplate: "https://bedrock-mantle.{region}.api.aws/anthropic",
|
|
38
|
+
keyEnv: "BEDROCK_API_KEY",
|
|
39
|
+
aliasesArePinned: true, // Bedrock model ids differ per region/deployment
|
|
40
|
+
implemented: true,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "vertex",
|
|
44
|
+
vendor: "Anthropic",
|
|
45
|
+
label: "Google Vertex AI",
|
|
46
|
+
hint: "your GCP project owns auth and billing",
|
|
47
|
+
wire: "anthropic",
|
|
48
|
+
aliasesArePinned: true,
|
|
49
|
+
implemented: false, // needs Google OAuth
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "openai-api",
|
|
53
|
+
vendor: "OpenAI",
|
|
54
|
+
label: "OpenAI API",
|
|
55
|
+
hint: "direct, with your own key",
|
|
56
|
+
wire: "openai",
|
|
57
|
+
baseUrl: "https://api.openai.com/v1",
|
|
58
|
+
keyEnv: "OPENAI_API_KEY",
|
|
59
|
+
implemented: true,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "ollama",
|
|
63
|
+
vendor: "Local",
|
|
64
|
+
label: "Ollama (local)",
|
|
65
|
+
hint: "runs on your machine, no key, no cost",
|
|
66
|
+
wire: "openai",
|
|
67
|
+
baseUrl: "http://localhost:11434/v1",
|
|
68
|
+
aliasesArePinned: true,
|
|
69
|
+
implemented: true,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "custom",
|
|
73
|
+
vendor: "Other",
|
|
74
|
+
label: "OpenAI-compatible endpoint",
|
|
75
|
+
hint: "OpenRouter, Groq, Together, vLLM, a gateway…",
|
|
76
|
+
wire: "openai",
|
|
77
|
+
needsBaseUrl: true,
|
|
78
|
+
keyEnv: "OPENAI_API_KEY",
|
|
79
|
+
aliasesArePinned: true,
|
|
80
|
+
implemented: true,
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
export function getRoute(id) {
|
|
84
|
+
return ROUTES.find((r) => r.id === id);
|
|
85
|
+
}
|
|
86
|
+
export const DEFAULT_REGION = "us-east-1";
|
|
87
|
+
/** Endpoint for a route, substituting the region into region-scoped URLs. */
|
|
88
|
+
export function baseUrlFor(route, region) {
|
|
89
|
+
if (route.baseUrlTemplate) {
|
|
90
|
+
return route.baseUrlTemplate.replace("{region}", region || DEFAULT_REGION);
|
|
91
|
+
}
|
|
92
|
+
return route.baseUrl;
|
|
93
|
+
}
|
|
94
|
+
/** Vendors in menu order, each with its routes. */
|
|
95
|
+
export function vendors() {
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const r of ROUTES) {
|
|
98
|
+
const found = out.find((v) => v.vendor === r.vendor);
|
|
99
|
+
if (found)
|
|
100
|
+
found.routes.push(r);
|
|
101
|
+
else
|
|
102
|
+
out.push({ vendor: r.vendor, routes: [r] });
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Built-in aliases for the direct Anthropic API. Ids on cloud routes differ
|
|
108
|
+
* per deployment and region, so those are pinned by the user instead.
|
|
109
|
+
*/
|
|
110
|
+
export const ANTHROPIC_MODELS = [
|
|
111
|
+
{ alias: "sonnet", id: "claude-sonnet-5", blurb: "balanced — good default for daily work" },
|
|
112
|
+
{ alias: "opus", id: "claude-opus-5", blurb: "most capable — complex, multi-step work" },
|
|
113
|
+
{ alias: "haiku", id: "claude-haiku-4-5-20251001", blurb: "fastest and cheapest — simple tasks" },
|
|
114
|
+
];
|
|
115
|
+
export const OPENAI_MODELS = [
|
|
116
|
+
{ alias: "gpt", id: "gpt-4o", blurb: "general purpose" },
|
|
117
|
+
{ alias: "mini", id: "gpt-4o-mini", blurb: "cheaper and faster" },
|
|
118
|
+
];
|
|
119
|
+
/** Models offered for a route; empty when ids must be pinned by the user. */
|
|
120
|
+
export function modelsForRoute(route) {
|
|
121
|
+
if (route.aliasesArePinned)
|
|
122
|
+
return [];
|
|
123
|
+
return route.wire === "anthropic" ? ANTHROPIC_MODELS : OPENAI_MODELS;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Turn whatever the user typed into a concrete model id.
|
|
127
|
+
* Order: explicit per-route pin > built-in alias > treat it as a raw id.
|
|
128
|
+
*/
|
|
129
|
+
export function resolveModel(input, route, pins = {}) {
|
|
130
|
+
const key = input.trim();
|
|
131
|
+
if (pins[key])
|
|
132
|
+
return pins[key];
|
|
133
|
+
const hit = modelsForRoute(route).find((m) => m.alias === key);
|
|
134
|
+
return hit ? hit.id : key;
|
|
135
|
+
}
|
|
136
|
+
/** Reverse lookup: show "sonnet (claude-sonnet-5)" instead of a bare id. */
|
|
137
|
+
export function describeModel(id, route, pins = {}) {
|
|
138
|
+
const pinned = Object.entries(pins).find(([, v]) => v === id);
|
|
139
|
+
if (pinned)
|
|
140
|
+
return `${pinned[0]} (${id})`;
|
|
141
|
+
const hit = modelsForRoute(route).find((m) => m.id === id);
|
|
142
|
+
return hit ? `${hit.alias} (${id})` : id;
|
|
143
|
+
}
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global settings: ~/.faber/settings.json
|
|
3
|
+
*
|
|
4
|
+
* Holds named profiles — a route, a model, and any endpoint or credential
|
|
5
|
+
* REFERENCE that route needs. Credentials themselves are never stored here:
|
|
6
|
+
* a profile names the environment variable to read (`apiKeyEnv`), so this file
|
|
7
|
+
* stays safe to sync between machines or check into a dotfiles repo.
|
|
8
|
+
*
|
|
9
|
+
* Resolution order, highest first:
|
|
10
|
+
* env vars > <project>/.faber/config.json > active profile > defaults
|
|
11
|
+
* Project settings beat the global profile so a repo can pin its own model,
|
|
12
|
+
* and env vars beat everything so CI and one-off overrides always work.
|
|
13
|
+
*/
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as os from "node:os";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
export const DEFAULT_SETTINGS = {
|
|
18
|
+
activeProfile: "default",
|
|
19
|
+
profiles: {
|
|
20
|
+
default: { route: "anthropic-api", model: "sonnet", apiKeyEnv: "ANTHROPIC_API_KEY" },
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
export function settingsPath() {
|
|
24
|
+
return path.join(os.homedir(), ".faber", "settings.json");
|
|
25
|
+
}
|
|
26
|
+
export function loadSettings(file = settingsPath()) {
|
|
27
|
+
try {
|
|
28
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
29
|
+
const profiles = raw.profiles && typeof raw.profiles === "object"
|
|
30
|
+
? raw.profiles
|
|
31
|
+
: DEFAULT_SETTINGS.profiles;
|
|
32
|
+
const active = typeof raw.activeProfile === "string" && profiles[raw.activeProfile]
|
|
33
|
+
? raw.activeProfile
|
|
34
|
+
: Object.keys(profiles)[0] ?? "default";
|
|
35
|
+
return { activeProfile: active, profiles };
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return structuredClone(DEFAULT_SETTINGS); // absent or malformed -> defaults
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function saveSettings(s, file = settingsPath()) {
|
|
42
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
43
|
+
fs.writeFileSync(file, JSON.stringify(s, null, 2) + "\n");
|
|
44
|
+
}
|
|
45
|
+
export function activeProfile(s) {
|
|
46
|
+
return s.profiles[s.activeProfile] ?? DEFAULT_SETTINGS.profiles.default;
|
|
47
|
+
}
|
|
48
|
+
/** Update the active profile in place and persist. */
|
|
49
|
+
export function updateActive(patch, file = settingsPath()) {
|
|
50
|
+
const s = loadSettings(file);
|
|
51
|
+
const name = s.activeProfile;
|
|
52
|
+
s.profiles[name] = { ...activeProfile(s), ...patch };
|
|
53
|
+
saveSettings(s, file);
|
|
54
|
+
return s;
|
|
55
|
+
}
|
package/dist/sigv4.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS SigV4 request signing.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: in SageMaker Studio, EC2, ECS, Lambda and anywhere else AWS
|
|
5
|
+
* injects an execution role, there IS no API key — credentials arrive as an
|
|
6
|
+
* access key / secret / session token trio, and requests are authenticated by
|
|
7
|
+
* signing them. That's the natural auth path in those environments, and a
|
|
8
|
+
* Bedrock API key would be a second, unnecessary credential.
|
|
9
|
+
*
|
|
10
|
+
* Implemented directly rather than pulling in the AWS SDK: the signing
|
|
11
|
+
* algorithm is ~80 lines of HMAC chaining, and the SDK would add dozens of
|
|
12
|
+
* transitive dependencies to a tool whose whole install story is "zero native
|
|
13
|
+
* deps, nothing to compile".
|
|
14
|
+
*
|
|
15
|
+
* Verified against the signing test vectors AWS publishes for the algorithm.
|
|
16
|
+
*/
|
|
17
|
+
import { createHash, createHmac } from "node:crypto";
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as os from "node:os";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
22
|
+
const hmac = (key, data) => createHmac("sha256", key).update(data, "utf8").digest();
|
|
23
|
+
/** ISO8601 basic format: 20260808T210000Z */
|
|
24
|
+
export function amzDate(d = new Date()) {
|
|
25
|
+
return d.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Canonical request -> string to sign -> signing key -> Authorization header.
|
|
29
|
+
* Header names are lowercased and sorted; the payload is hashed. Any deviation
|
|
30
|
+
* produces a signature mismatch, so the ordering here is load-bearing.
|
|
31
|
+
*/
|
|
32
|
+
export function signRequest(opts) {
|
|
33
|
+
const { method, body, region, service, credentials } = opts;
|
|
34
|
+
const url = new URL(opts.url);
|
|
35
|
+
const now = opts.now ?? new Date();
|
|
36
|
+
const stamp = amzDate(now);
|
|
37
|
+
const date = stamp.slice(0, 8);
|
|
38
|
+
const headers = {
|
|
39
|
+
host: url.host,
|
|
40
|
+
"x-amz-date": stamp,
|
|
41
|
+
...Object.fromEntries(Object.entries(opts.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v])),
|
|
42
|
+
};
|
|
43
|
+
if (credentials.sessionToken)
|
|
44
|
+
headers["x-amz-security-token"] = credentials.sessionToken;
|
|
45
|
+
const signedHeaderNames = Object.keys(headers).sort();
|
|
46
|
+
const canonicalHeaders = signedHeaderNames
|
|
47
|
+
.map((h) => `${h}:${headers[h].trim().replace(/\s+/g, " ")}\n`)
|
|
48
|
+
.join("");
|
|
49
|
+
const signedHeaders = signedHeaderNames.join(";");
|
|
50
|
+
// query params must be sorted and percent-encoded
|
|
51
|
+
const canonicalQuery = [...url.searchParams.entries()]
|
|
52
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
53
|
+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
|
54
|
+
.join("&");
|
|
55
|
+
const payloadHash = sha256(body);
|
|
56
|
+
const canonicalRequest = [
|
|
57
|
+
method.toUpperCase(),
|
|
58
|
+
url.pathname || "/",
|
|
59
|
+
canonicalQuery,
|
|
60
|
+
canonicalHeaders,
|
|
61
|
+
signedHeaders,
|
|
62
|
+
payloadHash,
|
|
63
|
+
].join("\n");
|
|
64
|
+
const scope = `${date}/${region}/${service}/aws4_request`;
|
|
65
|
+
const stringToSign = [
|
|
66
|
+
"AWS4-HMAC-SHA256",
|
|
67
|
+
stamp,
|
|
68
|
+
scope,
|
|
69
|
+
sha256(canonicalRequest),
|
|
70
|
+
].join("\n");
|
|
71
|
+
const kDate = hmac(`AWS4${credentials.secretAccessKey}`, date);
|
|
72
|
+
const kRegion = hmac(kDate, region);
|
|
73
|
+
const kService = hmac(kRegion, service);
|
|
74
|
+
const kSigning = hmac(kService, "aws4_request");
|
|
75
|
+
const signature = createHmac("sha256", kSigning).update(stringToSign, "utf8").digest("hex");
|
|
76
|
+
return {
|
|
77
|
+
...headers,
|
|
78
|
+
authorization: `AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${scope}, ` +
|
|
79
|
+
`SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
|
80
|
+
"x-amz-content-sha256": payloadHash,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
// ────────────────────────────────────────────── credential discovery
|
|
84
|
+
/**
|
|
85
|
+
* Find AWS credentials the way the SDKs do, in the same order. In SageMaker
|
|
86
|
+
* Studio, ECS and Lambda the environment or the container endpoint is
|
|
87
|
+
* populated automatically, so this returns credentials with no setup at all.
|
|
88
|
+
*/
|
|
89
|
+
export async function discoverAwsCredentials(profileName = process.env.AWS_PROFILE ?? "default") {
|
|
90
|
+
// 1. environment (SageMaker Studio, CI, explicit exports)
|
|
91
|
+
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
|
|
92
|
+
return {
|
|
93
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
|
94
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
|
95
|
+
sessionToken: process.env.AWS_SESSION_TOKEN,
|
|
96
|
+
source: "environment",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
// 2. container credential endpoint (ECS, SageMaker, CodeBuild)
|
|
100
|
+
const relUri = process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI;
|
|
101
|
+
const fullUri = process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI;
|
|
102
|
+
if (relUri || fullUri) {
|
|
103
|
+
const url = fullUri ?? `http://169.254.170.2${relUri}`;
|
|
104
|
+
const creds = await fetchContainerCredentials(url);
|
|
105
|
+
if (creds)
|
|
106
|
+
return creds;
|
|
107
|
+
}
|
|
108
|
+
// 3. shared credentials file (a laptop with `aws configure` run)
|
|
109
|
+
const fromFile = readSharedCredentials(profileName);
|
|
110
|
+
if (fromFile)
|
|
111
|
+
return fromFile;
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
async function fetchContainerCredentials(url) {
|
|
115
|
+
try {
|
|
116
|
+
const ctl = new AbortController();
|
|
117
|
+
const timer = setTimeout(() => ctl.abort(), 3000);
|
|
118
|
+
const headers = {};
|
|
119
|
+
const token = process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN;
|
|
120
|
+
if (token)
|
|
121
|
+
headers.authorization = token;
|
|
122
|
+
const res = await fetch(url, { headers, signal: ctl.signal });
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
if (!res.ok)
|
|
125
|
+
return undefined;
|
|
126
|
+
const b = await res.json();
|
|
127
|
+
if (!b.AccessKeyId || !b.SecretAccessKey)
|
|
128
|
+
return undefined;
|
|
129
|
+
return {
|
|
130
|
+
accessKeyId: b.AccessKeyId,
|
|
131
|
+
secretAccessKey: b.SecretAccessKey,
|
|
132
|
+
sessionToken: b.Token,
|
|
133
|
+
source: "container credentials endpoint",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Minimal INI reader for ~/.aws/credentials — no dependency needed. */
|
|
141
|
+
export function readSharedCredentials(profileName = "default", file = path.join(os.homedir(), ".aws", "credentials")) {
|
|
142
|
+
let text;
|
|
143
|
+
try {
|
|
144
|
+
text = fs.readFileSync(file, "utf8");
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
const wanted = profileName.replace(/^profile\s+/, "");
|
|
150
|
+
let current = "";
|
|
151
|
+
const section = {};
|
|
152
|
+
for (const raw of text.split("\n")) {
|
|
153
|
+
const line = raw.split(/[#;]/)[0].trim();
|
|
154
|
+
if (!line)
|
|
155
|
+
continue;
|
|
156
|
+
const header = /^\[(.+)\]$/.exec(line);
|
|
157
|
+
if (header) {
|
|
158
|
+
current = header[1].replace(/^profile\s+/, "").trim();
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (current !== wanted)
|
|
162
|
+
continue;
|
|
163
|
+
const eq = line.indexOf("=");
|
|
164
|
+
if (eq === -1)
|
|
165
|
+
continue;
|
|
166
|
+
section[line.slice(0, eq).trim().toLowerCase()] = line.slice(eq + 1).trim();
|
|
167
|
+
}
|
|
168
|
+
const id = section["aws_access_key_id"], secret = section["aws_secret_access_key"];
|
|
169
|
+
if (!id || !secret)
|
|
170
|
+
return undefined;
|
|
171
|
+
return {
|
|
172
|
+
accessKeyId: id,
|
|
173
|
+
secretAccessKey: secret,
|
|
174
|
+
sessionToken: section["aws_session_token"],
|
|
175
|
+
source: `~/.aws/credentials [${wanted}]`,
|
|
176
|
+
};
|
|
177
|
+
}
|
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, priceAgeDays, STALE_AFTER_DAYS } 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,28 @@ 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
|
+
}
|
|
23
37
|
this.registerProject();
|
|
24
38
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Record a completed task. Cost is computed and stored NOW, using the prices
|
|
41
|
+
* in effect at this moment — later price changes never rewrite it.
|
|
42
|
+
*/
|
|
43
|
+
record(u, model, override) {
|
|
44
|
+
this.currentModel = model;
|
|
45
|
+
const p = priceFor(model, override);
|
|
46
|
+
const cost = UsageLedger.cost(u, p) ?? null;
|
|
47
|
+
const saved = UsageLedger.saved(u, p) ?? null;
|
|
48
|
+
this.db.prepare("INSERT INTO tasks VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
49
|
+
.run(Date.now(), u.input, u.cacheRead, u.cacheWrite, u.output, u.calls, model, cost, saved);
|
|
28
50
|
this.session.input += u.input;
|
|
29
51
|
this.session.cacheRead += u.cacheRead;
|
|
30
52
|
this.session.cacheWrite += u.cacheWrite;
|
|
@@ -38,18 +60,38 @@ export class UsageLedger {
|
|
|
38
60
|
COALESCE(SUM(calls),0) calls
|
|
39
61
|
FROM tasks WHERE ts >= ?`).get(sinceMs ?? 0);
|
|
40
62
|
}
|
|
41
|
-
/** Cost in USD
|
|
42
|
-
static cost(u,
|
|
43
|
-
if (!
|
|
63
|
+
/** Cost in USD for one usage record at a given model's prices. */
|
|
64
|
+
static cost(u, p) {
|
|
65
|
+
if (!p)
|
|
44
66
|
return undefined;
|
|
45
|
-
return (u.input *
|
|
46
|
-
u.cacheWrite *
|
|
67
|
+
return (u.input * p.in + u.cacheRead * cacheReadPrice(p) +
|
|
68
|
+
u.cacheWrite * cacheWritePrice(p) + u.output * p.out) / 1e6;
|
|
47
69
|
}
|
|
48
|
-
/**
|
|
49
|
-
static saved(u,
|
|
50
|
-
if (!
|
|
70
|
+
/** What caching saved vs paying full input price, net of the write premium. */
|
|
71
|
+
static saved(u, p) {
|
|
72
|
+
if (!p)
|
|
51
73
|
return undefined;
|
|
52
|
-
|
|
74
|
+
const readSaving = u.cacheRead * (p.in - cacheReadPrice(p));
|
|
75
|
+
const writePremium = u.cacheWrite * (cacheWritePrice(p) - p.in);
|
|
76
|
+
return (readSaving - writePremium) / 1e6;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Totals split by model, so a history spanning several models is priced with
|
|
80
|
+
* each model's own rates rather than whatever is loaded right now.
|
|
81
|
+
*/
|
|
82
|
+
get startedAt() { return this.sessionStart; }
|
|
83
|
+
totalsByModel(sinceMs) {
|
|
84
|
+
const rows = this.db.prepare(`SELECT model, COUNT(*) tasks, COALESCE(SUM(input),0) input,
|
|
85
|
+
COALESCE(SUM(cache_read),0) cacheRead, COALESCE(SUM(cache_write),0) cacheWrite,
|
|
86
|
+
COALESCE(SUM(output),0) output, COALESCE(SUM(calls),0) calls,
|
|
87
|
+
SUM(cost) billedCost, SUM(saved) billedSaved,
|
|
88
|
+
SUM(CASE WHEN cost IS NULL THEN 1 ELSE 0 END) unpriced
|
|
89
|
+
FROM tasks WHERE ts >= ? GROUP BY model`).all(sinceMs ?? 0);
|
|
90
|
+
return rows.map((r) => ({
|
|
91
|
+
model: r.model,
|
|
92
|
+
totals: r,
|
|
93
|
+
billed: { cost: r.billedCost, saved: r.billedSaved, unpriced: r.unpriced },
|
|
94
|
+
}));
|
|
53
95
|
}
|
|
54
96
|
// ---- global registry so usage can be summed across every project ----
|
|
55
97
|
registerProject() {
|
|
@@ -104,31 +146,89 @@ export class UsageLedger {
|
|
|
104
146
|
const k = (n) => n >= 1_000_000 ? (n / 1_000_000).toFixed(1) + "M"
|
|
105
147
|
: n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n);
|
|
106
148
|
const money = (v) => v === undefined ? "—" : `$${v.toFixed(2)}`;
|
|
107
|
-
/**
|
|
108
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Render the /usage panel. Each row is priced per model using that model's own
|
|
151
|
+
* rates, so a history that spans a switch from Sonnet to Opus stays accurate.
|
|
152
|
+
*/
|
|
153
|
+
export function renderUsagePanel(ledger, override) {
|
|
109
154
|
const midnight = new Date();
|
|
110
155
|
midnight.setHours(0, 0, 0, 0);
|
|
111
|
-
const
|
|
112
|
-
["this session",
|
|
113
|
-
["today",
|
|
114
|
-
["all time",
|
|
156
|
+
const windows = [
|
|
157
|
+
["this session", undefined],
|
|
158
|
+
["today", midnight.getTime()],
|
|
159
|
+
["all time", 0],
|
|
115
160
|
];
|
|
116
161
|
const lines = [];
|
|
117
162
|
const W = [13, 7, 12, 9, 7, 9, 9];
|
|
118
163
|
const cells = (c) => c.map((s, i) => s.padEnd(W[i])).join(" ");
|
|
119
164
|
lines.push(cells(["", "tasks", "in", "cached", "out", "cost", "saved"]));
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
165
|
+
let anyPriced = false;
|
|
166
|
+
let legacyRows = 0;
|
|
167
|
+
let estimated = false;
|
|
168
|
+
for (const [label, since] of windows) {
|
|
169
|
+
// session totals come from memory; the rest are priced per model from disk
|
|
170
|
+
// Every window is priced per model — including the session, which would
|
|
171
|
+
// otherwise apply the last-used model's rates to earlier tasks.
|
|
172
|
+
const bands = ledger.totalsByModel(since ?? ledger.startedAt);
|
|
173
|
+
const agg = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, calls: 0, tasks: 0 };
|
|
174
|
+
let cost;
|
|
175
|
+
let saved;
|
|
176
|
+
for (const b of bands) {
|
|
177
|
+
agg.input += b.totals.input;
|
|
178
|
+
agg.cacheRead += b.totals.cacheRead;
|
|
179
|
+
agg.cacheWrite += b.totals.cacheWrite;
|
|
180
|
+
agg.output += b.totals.output;
|
|
181
|
+
agg.calls += b.totals.calls;
|
|
182
|
+
agg.tasks += b.totals.tasks;
|
|
183
|
+
// Historical cost is whatever was charged at the time — never repriced.
|
|
184
|
+
if (b.billed.cost !== null) {
|
|
185
|
+
cost = (cost ?? 0) + b.billed.cost;
|
|
186
|
+
anyPriced = true;
|
|
187
|
+
}
|
|
188
|
+
if (b.billed.saved !== null)
|
|
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;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
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
|
+
}
|
|
211
|
+
if (anyPriced) {
|
|
212
|
+
const src = readPriceCache();
|
|
213
|
+
lines.push("");
|
|
214
|
+
lines.push("cost is what each task was charged when it ran — later price changes don't rewrite it");
|
|
215
|
+
const age = priceAgeDays();
|
|
216
|
+
if (src && age !== undefined) {
|
|
217
|
+
const when = new Date(src.fetchedAt).toISOString().slice(0, 10);
|
|
218
|
+
lines.push(age > STALE_AFTER_DAYS
|
|
219
|
+
? `new tasks priced from rates fetched ${when} (${Math.round(age)} days ago — consider /usage --refresh-prices)`
|
|
220
|
+
: `new tasks priced from rates fetched ${when}`);
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
lines.push(`new tasks priced from built-in rates, as of ${PRICES_AS_OF} — /usage --refresh-prices for current`);
|
|
224
|
+
}
|
|
225
|
+
if (legacyRows) {
|
|
226
|
+
lines.push(`${legacyRows} older task(s) predate cost tracking${estimated ? " — estimated at today's rates" : ""}`);
|
|
227
|
+
}
|
|
128
228
|
}
|
|
129
|
-
|
|
229
|
+
else {
|
|
130
230
|
lines.push("");
|
|
131
|
-
lines.push("
|
|
231
|
+
lines.push("no price known for this model — set FABER_PRICE_IN / FABER_PRICE_OUT");
|
|
132
232
|
}
|
|
133
233
|
const others = UsageLedger.allProjects().filter((p) => p.totals.tasks > 0);
|
|
134
234
|
if (others.length > 1) {
|
|
@@ -139,7 +239,7 @@ export function renderUsagePanel(ledger, priceIn, priceOut) {
|
|
|
139
239
|
lines.push(cells([
|
|
140
240
|
" " + path.basename(project).slice(0, 11), String(t.tasks), k(totalIn),
|
|
141
241
|
totalIn > 0 ? Math.round((t.cacheRead / totalIn) * 100) + "%" : "0%",
|
|
142
|
-
k(t.output),
|
|
242
|
+
k(t.output), "", "",
|
|
143
243
|
]));
|
|
144
244
|
}
|
|
145
245
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberwright",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|
|
@@ -45,5 +45,13 @@
|
|
|
45
45
|
"developer-tools",
|
|
46
46
|
"terminal",
|
|
47
47
|
"llm"
|
|
48
|
-
]
|
|
48
|
+
],
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "git+https://github.com/JibanKumar-cloud/faber.git"
|
|
52
|
+
},
|
|
53
|
+
"homepage": "https://github.com/JibanKumar-cloud/faber#readme",
|
|
54
|
+
"bugs": {
|
|
55
|
+
"url": "https://github.com/JibanKumar-cloud/faber/issues"
|
|
56
|
+
}
|
|
49
57
|
}
|