faberwright 0.4.1 → 0.4.3
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 +80 -16
- package/dist/commands.js +35 -0
- package/dist/index.js +21 -1
- package/dist/llm.js +108 -8
- package/dist/onboard.js +145 -16
- package/dist/pricing.js +15 -0
- package/dist/prompt.js +8 -0
- package/dist/routes.js +33 -0
- package/dist/sigv4.js +50 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*Faber — Latin for craftsman. Yours lives at `faber`.*
|
|
6
6
|
* Read: [Faber Medium](https://medium.com/@jshial25/why-should-an-ai-coding-agent-read-hundreds-of-files-to-answer-one-question-d6369d29dfa5?postPublishedType=repub)
|
|
7
7
|
## Code Graph
|
|
8
|
-
<img width="636" height="573" alt="Screenshot 2026-08-09 at 2 58 30
|
|
8
|
+
<img width="636" height="573" alt="Screenshot 2026-08-09 at 2 58 30 PM" src="https://github.com/user-attachments/assets/176e5093-8e29-452f-a749-d06a561c583a" />
|
|
9
9
|
|
|
10
10
|
## Demo
|
|
11
11
|
<img width="1240" height="700" alt="terminal_demo_compressed" src="https://github.com/user-attachments/assets/f78a8f68-130e-4f37-90f2-7a7089fa2653" />
|
|
@@ -21,7 +21,7 @@ faber # start the REPL (approval mode is on by default)
|
|
|
21
21
|
1. **Give it a real task.** Try `add a comment explaining what the main entry file does`
|
|
22
22
|
2. **Approve the diff** with the arrow keys and Enter. Cursor starts on Yes; "Always this session" grants trust. Shell commands ask too.
|
|
23
23
|
3. **See the safety net.** `/history` lists every task with the files it touched.
|
|
24
|
-
4. **Undo it.** `/undo` reverts the task, and
|
|
24
|
+
4. **Undo it.** `/undo` reverts the task, and `/redo` brings it back. Nothing is ever lost in either direction.
|
|
25
25
|
5. **Come back tomorrow.** `faber --resume` continues the conversation, or just ask *"what did we do last time?"* — it searches past sessions itself.
|
|
26
26
|
|
|
27
27
|
That loop of task, approve, inspect, revert is the whole trust model. Everything else is detail.
|
|
@@ -30,7 +30,7 @@ That loop of task, approve, inspect, revert is the whole trust model. Everything
|
|
|
30
30
|
|
|
31
31
|
**It has a map, not just eyes.** The code graph stores call and import *edges*, incrementally updated in milliseconds. One ~50-token query (`trace_path(main, saveUser)` → `main → startServer → handleSignup → saveUser`) replaces reading thousands of tokens of files. `/map main` prints the call tree — the "trace it from main" ritual every programmer does, automated.
|
|
32
32
|
|
|
33
|
-
**Every model you can reach, including the coding ones.** Claude through the Anthropic API
|
|
33
|
+
**Every model you can reach, including the coding ones.** Claude through the Anthropic API, your own AWS account, or Microsoft Foundry on Azure. OpenAI through both of its APIs — chat completions and the Responses API that the codex family requires — directly or through your Azure subscription. Plus anything OpenAI-compatible, and local models through Ollama for no key and no cost. Faber reads which endpoint each model needs and routes there itself, so `gpt-5.3-codex` and `claude-opus-5` are both just entries in the same list, priced side by side.
|
|
34
34
|
|
|
35
35
|
**It's honest about money.** Prompt caching marks the stable prefix of every request, so repeat loop iterations pay ~10% for tokens already sent. Every task is costed and recorded as it runs, so `/usage` is a ledger of what you actually spent rather than an estimate — broken down by time window and by model, with the cheapest option always visible next to the one you're using.
|
|
36
36
|
|
|
@@ -40,9 +40,45 @@ That loop of task, approve, inspect, revert is the whole trust model. Everything
|
|
|
40
40
|
|
|
41
41
|
**You can steer it mid-flight.** See it going the wrong way? Just type `use TypeScript, not JavaScript` — and your guidance is injected at the next loop iteration. No cancelling, no wasted tokens.
|
|
42
42
|
|
|
43
|
+
## Upgrading from Codewright
|
|
44
|
+
|
|
45
|
+
Faber is Codewright renamed, after the npm name was taken between building and releasing. Existing `.codewright/` state directories are adopted automatically; `CW_*` environment variables still work alongside `FABER_*`.
|
|
46
|
+
|
|
43
47
|
## Requirements & install
|
|
44
48
|
|
|
45
|
-
|
|
49
|
+
Faber needs **Node.js 22.5 or newer**. It uses Node's built-in SQLite, so there are no native dependencies to compile and installs don't fail on a missing toolchain. The only runtime dependencies are two small pure-JS packages.
|
|
50
|
+
|
|
51
|
+
Check your version:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
node --version
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
If it prints `v22.5.0` or higher, skip ahead to Install.
|
|
58
|
+
|
|
59
|
+
### Installing Node.js
|
|
60
|
+
|
|
61
|
+
The recommended way is **nvm**, which lets you switch versions per project without touching your system:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# macOS / Linux
|
|
65
|
+
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
|
|
66
|
+
# restart your terminal, or: source ~/.zshrc
|
|
67
|
+
nvm install 22
|
|
68
|
+
nvm use 22
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Alternatives:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
brew install node # macOS, Homebrew
|
|
75
|
+
winget install OpenJS.NodeJS # Windows
|
|
76
|
+
sudo apt install nodejs npm # Debian/Ubuntu — often ships an older version; prefer nvm
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Or download an installer from [nodejs.org](https://nodejs.org). Already on Node but below 22.5? `nvm install 22 && nvm use 22`, or `brew upgrade node`.
|
|
80
|
+
|
|
81
|
+
### Install Faber
|
|
46
82
|
|
|
47
83
|
```bash
|
|
48
84
|
npm install -g faberwright
|
|
@@ -93,10 +129,18 @@ Setup is skipped entirely when there's no terminal attached, so scripts and CI n
|
|
|
93
129
|
|
|
94
130
|
## Features
|
|
95
131
|
|
|
96
|
-
|
|
|
132
|
+
| | |
|
|
97
133
|
|---|---|
|
|
98
134
|
| **Streaming agent loop** | Text renders as generated, with a live heartbeat (`✳ Working… 14s`) that never interleaves with output and ends in `✳ Worked for 14s`. Plan → tool → observe → adjust, until done. Hard iteration cap. |
|
|
99
135
|
| **Code graph** | Symbols *and* call/import edges, incrementally maintained (only changed files re-parse). Agent tools: `who_calls` (blast radius), `calls_from` (dependencies), `trace_path` (workflow chain). A compact repo map of the most-connected symbols orients every task. Edges are static hints — dynamic dispatch/DI/events aren't captured; the agent reads code where precision matters. |
|
|
136
|
+
| **Approval by default** | Arrow-key menu on every file edit (colored diff) and every shell command. `--auto` / `/auto` / `FABER_APPROVAL=auto` opts into autonomy. |
|
|
137
|
+
| **Two OpenAI APIs** | Chat completions and the Responses API, chosen per model from the endpoint each one publishes. The codex family works without configuration. |
|
|
138
|
+
| **Guided setup** | First run walks through vendor, route, credential and model, then remembers it. Later launches verify the setup can reach a model before opening the prompt. Nothing is saved until setup finishes, so an interrupted run leaves no half-configured state. |
|
|
139
|
+
| **Credentials** | Keys live in `~/.faber/credentials.json`, owner-only, outside every repository. Faber checks that what you paste looks like a key before storing it, hides it as you type, and never prints more than a masked fragment. Environment variables keep working and take precedence. |
|
|
140
|
+
| **Interactive choices** | Genuinely ambiguous request? The agent presents 2–4 options plus "Chat more about this instead" before writing code. |
|
|
141
|
+
| **Mid-task steering** | Type while it works; guidance is injected at the next loop boundary. If the model finishes while steering is queued, the task continues instead. Steering markers carry a per-task nonce, so hostile file contents can't impersonate you. |
|
|
142
|
+
| **Paste as chips** | Raw-mode composer: pasting a 50-line block renders only a chip — `[pasted #1 +50 lines]` — never the code itself, while the full text is expanded into the message on Enter. Paste the same block again to expand it visibly. Paste, type, paste again: one submission. Full line editing: arrows, Home/End, forward-delete, Ctrl-A/E/K/U, Up/Down history; long drafts wrap across rows with exact cursor tracking — backspace and arrows travel across wrap boundaries. Chips are atomic — one arrow step, one backspace. Works at the prompt and while steering. |
|
|
143
|
+
| **Reversible history** | `/history` lists tasks with files touched; `/restore <id>` jumps anywhere; `/undo` / `/redo` — restores are never destructive. |
|
|
100
144
|
| **Git-aware** | Warns about uncommitted changes at startup. Optional `FABER_GIT=commit`: one commit per completed *task* (never per edit). The agent never commits by default. |
|
|
101
145
|
| **Two-layer memory** | Short-term: token-budgeted window, auto-summarized past 60k (tool pairs never split). Long-term: SQLite+FTS5 facts/decisions/gotchas + per-file notes, with full lifecycle (`/forget`, `/archive`, `/prune`). |
|
|
102
146
|
| **Sessions** | Crash-safe JSONL transcripts; `--resume`; last-session digest injected at startup; `recall_sessions` keyword search across history. |
|
|
@@ -106,11 +150,6 @@ Setup is skipped entirely when there's no terminal attached, so scripts and CI n
|
|
|
106
150
|
| **Usage dashboard** | `/usage` shows a persistent ledger: tasks, tokens, cache rate, cost, and cache *savings* — for this session, today, and all time, plus totals across every project on the machine. Costs use each model's own rates, including its exact cache read/write prices, so a history spanning a model switch stays accurate. Prices ship built in and update with `/usage --refresh-prices`. Records live in `.faber/usage.db` per project and survive restarts. |
|
|
107
151
|
| **Error recovery** | Transient API errors: backoff + jitter, honors Retry-After. Tool errors return to the model to self-correct. Identical call failing twice → warning; three times → clean abort. Ctrl-C aborts streams *and* running commands; checkpoints survive. |
|
|
108
152
|
| **Safety rails** | Symlink-resolved path jail, shell denylist, timeouts, output truncation, atomic writes (temp+rename), stale-edit guard. Guardrails, not a sandbox — use a container for untrusted code. |
|
|
109
|
-
| **Approval by default** | Arrow-key menu on every file edit (colored diff) and every shell command. `--auto` / `/auto` / `FABER_APPROVAL=auto` opts into autonomy. |
|
|
110
|
-
| **Guided setup** | First run walks through vendor, route, credential and model, then remembers it. Later launches verify the setup can reach a model before opening the prompt. Nothing is saved until setup finishes, so an interrupted run leaves no half-configured state. |
|
|
111
|
-
| **Credentials** | Keys live in `~/.faber/credentials.json`, owner-only, outside every repository. Faber checks that what you paste looks like a key before storing it, hides it as you type, and never prints more than a masked fragment. Environment variables keep working and take precedence. |
|
|
112
|
-
| **Mid-task steering** | Type while it works; guidance is injected at the next loop boundary. If the model finishes while steering is queued, the task continues instead. Steering markers carry a per-task nonce, so hostile file contents can't impersonate you. |
|
|
113
|
-
| **Reversible history** | `/history` lists tasks with files touched; `/restore <id>` jumps anywhere; `/undo` / `/redo` — restores are never destructive. |
|
|
114
153
|
|
|
115
154
|
## Vendors, routes, and models
|
|
116
155
|
|
|
@@ -122,22 +161,24 @@ Faber separates three choices, so you can change one without redoing the others:
|
|
|
122
161
|
| **Route** | how you reach it and who owns auth | `/route` |
|
|
123
162
|
| **Model** | which model on that route | `/model` (searchable model options) |
|
|
124
163
|
|
|
125
|
-
Routes available today: **Anthropic API**, **Amazon Bedrock**, **OpenAI API**, **Ollama** (local, no key and no cost), and any **OpenAI-compatible endpoint** (OpenRouter, Groq, Together, vLLM, a gateway). Google Vertex is defined but not yet wired up, and `/route` says so rather than failing at request time.
|
|
164
|
+
Routes available today: **Anthropic API**, **Amazon Bedrock**, **Microsoft Foundry**, **OpenAI API**, **Azure OpenAI**, **Ollama** (local, no key and no cost), and any **OpenAI-compatible endpoint** (OpenRouter, Groq, Together, vLLM, a gateway). Google Vertex is defined but not yet wired up, and `/route` says so rather than failing at request time.
|
|
126
165
|
|
|
127
166
|
On OpenAI, Faber speaks both APIs. Chat completions for most models, and the **Responses API** for the ones that require it, which includes the codex family — the coding-tuned models a coding agent actually wants. You don't choose: each model's endpoint is recorded in the price dataset Faber already downloads, so `gpt-5.3-codex` routes to `/v1/responses` and `gpt-5.5` to `/v1/chat/completions` automatically. If a model is too new to appear in that dataset and the API says it belongs elsewhere, Faber retries on the endpoint it names rather than failing.
|
|
128
167
|
|
|
129
168
|
### Amazon Bedrock
|
|
130
169
|
|
|
131
|
-
|
|
170
|
+
Setup asks how you want to authenticate, because the choice affects who gets billed.
|
|
132
171
|
|
|
133
|
-
**
|
|
172
|
+
**AWS credentials (IAM role)** needs no key at all. In SageMaker Studio, on EC2, in ECS or Lambda, or on a laptop where you've run `aws configure`, Faber finds your credentials the same way the SDKs do and signs each request with SigV4. The region comes from `AWS_REGION` or `~/.aws/config` when it's there, so usually nothing is asked at all. Signing is implemented directly against Node's crypto module rather than pulling in the AWS SDK, and is verified against the signature AWS publishes for its own worked example.
|
|
134
173
|
|
|
135
|
-
**
|
|
174
|
+
**A Bedrock API key** bills through that key instead:
|
|
136
175
|
|
|
137
176
|
```bash
|
|
138
177
|
export BEDROCK_API_KEY=...
|
|
139
178
|
```
|
|
140
179
|
|
|
180
|
+
Either path must complete: pick the role with no credentials present, or the key without entering one, and setup saves nothing and starts over next time.
|
|
181
|
+
|
|
141
182
|
Model ids on Bedrock differ by region and deployment, so pin them per alias in your profile instead of relying on the built-in names:
|
|
142
183
|
|
|
143
184
|
```json
|
|
@@ -146,6 +187,28 @@ Model ids on Bedrock differ by region and deployment, so pin them per alias in y
|
|
|
146
187
|
"apiKeyEnv": "BEDROCK_API_KEY" }
|
|
147
188
|
```
|
|
148
189
|
|
|
190
|
+
### Claude on Azure (Microsoft Foundry)
|
|
191
|
+
|
|
192
|
+
Claude has been generally available in Microsoft Foundry since June 2026, hosted on Azure with your organisation's own authentication, billing and governance — usage appears on the Azure invoice and can count toward a Microsoft Azure Consumption Commitment.
|
|
193
|
+
|
|
194
|
+
It serves the same Messages API as the direct route, so for Faber this is a base URL and an auth header rather than a new protocol: prompt caching, extended thinking and tool streaming all work unchanged. Setup asks for your resource name and takes either a subscription key or a token minted from Entra ID, recognising which you gave it.
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
export AZURE_FOUNDRY_API_KEY=...
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
A Foundry resource is separate from an Azure OpenAI one and has its own key, so the two never share a credential.
|
|
201
|
+
|
|
202
|
+
### Azure OpenAI
|
|
203
|
+
|
|
204
|
+
The answer for anyone who wants OpenAI's coding models billed through their employer rather than a personal card: AWS doesn't host OpenAI models, so Bedrock isn't the route for them — Azure is.
|
|
205
|
+
|
|
206
|
+
Setup asks for your resource name rather than a URL, since that's the part people know from the portal, and builds the endpoint from it. Azure addresses **deployments** rather than model ids: you call `my-codex-deployment`, a name someone in your organisation chose, and the deployment decides which model runs. Faber lists the deployments your subscription has created and shows the model behind each one.
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
export AZURE_OPENAI_API_KEY=...
|
|
210
|
+
```
|
|
211
|
+
|
|
149
212
|
### Costs
|
|
150
213
|
|
|
151
214
|
`/usage` is a ledger, not an estimate. Every task's cost is worked out and written down when the task runs, at the rates in effect then, and never recalculated. If a vendor raises prices next month, last month's tasks still show what they actually cost.
|
|
@@ -177,9 +240,8 @@ model gpt-5.3-codex which model ran it
|
|
|
177
240
|
cost 0.0412 dollars, frozen at run time
|
|
178
241
|
saved 0.0231 what caching avoided
|
|
179
242
|
```
|
|
180
|
-
**Cost metrics `/usage`:
|
|
181
243
|
|
|
182
|
-
<img width="636" height="388" alt="Screenshot 2026-08-10 at 12 25 57
|
|
244
|
+
<img width="636" height="388" alt="Screenshot 2026-08-10 at 12 25 57 AM" src="https://github.com/user-attachments/assets/33ba06ce-69ad-4afe-afcc-6119a897e67a" />
|
|
183
245
|
|
|
184
246
|
|
|
185
247
|
Counters and a timestamp. No prompts, no code, no file contents. Around 80 bytes a row, so a year of heavy use is roughly a megabyte, and nothing is ever pruned.
|
|
@@ -244,6 +306,8 @@ Flags: `faber [task] [--workspace|-w <dir>] [--resume] [--ask|--auto] [--version
|
|
|
244
306
|
| `FABER_ROUTE`, `FABER_REGION` | route id and region, overriding the profile |
|
|
245
307
|
| `FABER_AUTO_PRICES` | `0` disables the background price refresh |
|
|
246
308
|
| `BEDROCK_API_KEY` | Bedrock, when you'd rather use a key than an IAM role |
|
|
309
|
+
| `AZURE_OPENAI_API_KEY` | Azure OpenAI, for GPT deployments in your subscription |
|
|
310
|
+
| `AZURE_FOUNDRY_API_KEY` | Microsoft Foundry, for Claude on Azure (a separate resource) |
|
|
247
311
|
| `AWS_ACCESS_KEY_ID` etc. | picked up automatically for Bedrock's SigV4 signing |
|
|
248
312
|
|
|
249
313
|
Faber keeps three files. `~/.faber/settings.json` holds your profiles, which is the route, model and which environment variable a credential comes from. `~/.faber/credentials.json` holds the keys themselves, owner-only and outside every repository. A project can override the profile with its own `.faber/config.json`, which is useful when one repo should use a cheaper model than the rest.
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deciding whether a line is a command.
|
|
3
|
+
*
|
|
4
|
+
* Kept in its own module because index.ts starts the CLI when imported, so a
|
|
5
|
+
* test that reaches for this function would otherwise boot the whole program
|
|
6
|
+
* and sit at a prompt forever.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* How many arguments each command takes. Anything with more is prose.
|
|
10
|
+
*
|
|
11
|
+
* The old rule was "starts with a slash", which read only the first word — so
|
|
12
|
+
* "/undo and /redo how does it work?" reverted the user's files while they
|
|
13
|
+
* were asking a question about them. A command has to match the WHOLE line.
|
|
14
|
+
*/
|
|
15
|
+
const COMMAND_ARITY = {
|
|
16
|
+
"/help": 0, "/exit": 0, "/quit": 0, "/undo": 0, "/redo": 0, "/clear": 0,
|
|
17
|
+
"/compact": 0, "/sessions": 0, "/history": 0, "/index": 0, "/setup": 0,
|
|
18
|
+
"/route": 0, "/ask": 0, "/auto": 0, "/verbose": 0, "/concise": 0,
|
|
19
|
+
"/usage": 1, // --refresh-prices
|
|
20
|
+
"/model": 1, // an id, --save or --refresh
|
|
21
|
+
"/profile": 1, // a profile name
|
|
22
|
+
"/map": 1, "/restore": 1, "/forget": 1, "/archive": 1, "/unarchive": 1,
|
|
23
|
+
"/prune": 1, "/memory": 1,
|
|
24
|
+
"/key": 2, // set|rm plus a variable name
|
|
25
|
+
};
|
|
26
|
+
export function looksLikeCommand(line) {
|
|
27
|
+
// Trim first: a pasted or indented line is still a command.
|
|
28
|
+
const parts = line.trim().split(/\s+/);
|
|
29
|
+
if (!parts[0]?.startsWith("/"))
|
|
30
|
+
return false;
|
|
31
|
+
const arity = COMMAND_ARITY[parts[0].toLowerCase()];
|
|
32
|
+
if (arity === undefined)
|
|
33
|
+
return false; // unknown: let /help catch it
|
|
34
|
+
return parts.length - 1 <= arity;
|
|
35
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -38,6 +38,7 @@ import { Composer } from "./editor.js";
|
|
|
38
38
|
import { StatusLine } from "./status.js";
|
|
39
39
|
import { renderMarkdown, StreamRenderer } from "./markdown.js";
|
|
40
40
|
import { renderUsagePanel, UsageLedger } from "./usage.js";
|
|
41
|
+
import { looksLikeCommand } from "./commands.js";
|
|
41
42
|
import { ROUTES, getRoute, describeModel, resolveModel, vendors, baseUrlFor, DEFAULT_REGION } from "./routes.js";
|
|
42
43
|
import { loadSettings, saveSettings, updateActive, settingsPath } from "./settings.js";
|
|
43
44
|
import { needsOnboarding, interactive, runOnboarding, reportSetup, setupComplete } from "./onboard.js";
|
|
@@ -60,6 +61,18 @@ function renderDiff(diff) {
|
|
|
60
61
|
: l.startsWith("@@") ? pc.cyan(l)
|
|
61
62
|
: pc.dim(l)).join("\n");
|
|
62
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Is this line a command, or a question that happens to mention one?
|
|
66
|
+
*
|
|
67
|
+
* Matching on the first word alone meant "/undo and /redo how does it work?"
|
|
68
|
+
* ran /undo and reverted the user's files. A destructive command must be the
|
|
69
|
+
* whole line — anything trailing means the person is talking, not commanding.
|
|
70
|
+
*/
|
|
71
|
+
const NO_ARG_COMMANDS = new Set([
|
|
72
|
+
"/undo", "/redo", "/history", "/clear", "/compact", "/sessions", "/help",
|
|
73
|
+
"/exit", "/quit", "/index", "/memory", "/ask", "/auto", "/verbose",
|
|
74
|
+
"/concise", "/setup", "/route",
|
|
75
|
+
]);
|
|
63
76
|
async function main() {
|
|
64
77
|
const argv = process.argv.slice(2);
|
|
65
78
|
const flags = new Set(argv.filter((a) => a.startsWith("-")));
|
|
@@ -638,7 +651,7 @@ async function main() {
|
|
|
638
651
|
}
|
|
639
652
|
if (!line)
|
|
640
653
|
continue;
|
|
641
|
-
if (line.startsWith("/")) {
|
|
654
|
+
if (line.startsWith("/") && looksLikeCommand(line)) {
|
|
642
655
|
if (!(await command(line)))
|
|
643
656
|
break;
|
|
644
657
|
continue;
|
|
@@ -653,6 +666,13 @@ async function main() {
|
|
|
653
666
|
}
|
|
654
667
|
}
|
|
655
668
|
const HELP = `
|
|
669
|
+
/setup set up again: vendor, route, credential, model
|
|
670
|
+
/route choose a vendor and how to reach it
|
|
671
|
+
/model [id] switch model; lists what your key can use, with prices
|
|
672
|
+
/key [set|rm] show, save or remove an API key (~/.faber, readable only by you)
|
|
673
|
+
/profile [name] list or switch saved profiles
|
|
674
|
+
/usage cost ledger by time window and model (--refresh-prices)
|
|
675
|
+
/verbose | /concise full-depth answers, or short ones (default concise)
|
|
656
676
|
/index rebuild the code graph (symbols + call edges)
|
|
657
677
|
/map <symbol> print the call tree from any entry point (e.g. /map main)
|
|
658
678
|
/memory show active long-term memories (+ file notes)
|
package/dist/llm.js
CHANGED
|
@@ -7,6 +7,8 @@ const RETRYABLE = new Set([408, 409, 429, 500, 502, 503, 504, 529]);
|
|
|
7
7
|
* is not a useful thing to tell someone whose key demonstrably works — the
|
|
8
8
|
* status code or error usually says exactly what went wrong.
|
|
9
9
|
*/
|
|
10
|
+
/** Azure pins the API surface by date; this one covers tools and streaming. */
|
|
11
|
+
export const AZURE_API_VERSION = "2024-10-21";
|
|
10
12
|
let lastListError;
|
|
11
13
|
export function lastModelListError() { return lastListError; }
|
|
12
14
|
/** AWS credentials discovered once per process for SigV4 routes. */
|
|
@@ -45,12 +47,20 @@ export class LLMClient {
|
|
|
45
47
|
* models route, which says nothing about the key and must not block setup.
|
|
46
48
|
*/
|
|
47
49
|
async verifyKey() {
|
|
50
|
+
if (this.config.route === "bedrock") {
|
|
51
|
+
// The mantle endpoint has no listing to probe, so treat a working
|
|
52
|
+
// credential discovery as sufficient; a bad one fails on first use.
|
|
53
|
+
return (await this.listBedrockModels()).length ? "ok" : "unreachable";
|
|
54
|
+
}
|
|
48
55
|
const anthropic = this.config.provider === "anthropic";
|
|
49
56
|
const url = anthropic ? `${this.config.baseUrl}/v1/models?limit=1`
|
|
50
57
|
: `${this.config.baseUrl}/models`;
|
|
58
|
+
const auth = await this.authHeaders(url, "", "GET");
|
|
51
59
|
const headers = anthropic
|
|
52
|
-
? {
|
|
53
|
-
:
|
|
60
|
+
? { ...auth, "anthropic-version": "2023-06-01" }
|
|
61
|
+
: this.config.route === "azure-openai"
|
|
62
|
+
? { "api-key": this.config.apiKey ?? "" }
|
|
63
|
+
: (this.config.apiKey ? { authorization: `Bearer ${this.config.apiKey}` } : auth);
|
|
54
64
|
try {
|
|
55
65
|
const ctl = new AbortController();
|
|
56
66
|
const timer = setTimeout(() => ctl.abort(), 8000);
|
|
@@ -64,15 +74,87 @@ export class LLMClient {
|
|
|
64
74
|
return "unreachable";
|
|
65
75
|
}
|
|
66
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Bedrock has no Messages-style listing: the mantle endpoint serves
|
|
79
|
+
* /v1/messages but returns 404 for /v1/models. AWS's own ListFoundationModels
|
|
80
|
+
* is the catalogue, on a different host and signed as a normal AWS call.
|
|
81
|
+
*/
|
|
82
|
+
async listBedrockModels() {
|
|
83
|
+
const region = this.config.region ?? "us-east-1";
|
|
84
|
+
const url = this.config.bedrockCatalogUrl
|
|
85
|
+
?? `https://bedrock.${region}.amazonaws.com/foundation-models`;
|
|
86
|
+
try {
|
|
87
|
+
const auth = await this.authHeaders(url, "", "GET");
|
|
88
|
+
const ctl = new AbortController();
|
|
89
|
+
const timer = setTimeout(() => ctl.abort(), 8000);
|
|
90
|
+
const res = await fetch(url, { headers: auth, signal: ctl.signal });
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
lastListError = `HTTP ${res.status} from ListFoundationModels`;
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
const body = await res.json();
|
|
97
|
+
return (body.modelSummaries ?? [])
|
|
98
|
+
.filter((m) => typeof m.modelId === "string"
|
|
99
|
+
&& /anthropic/i.test(m.providerName ?? m.modelId)
|
|
100
|
+
// skip image and embedding variants; we only run text
|
|
101
|
+
&& (!m.outputModalities || m.outputModalities.includes("TEXT")))
|
|
102
|
+
.map((m) => ({ id: m.modelId, name: m.modelName }));
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
lastListError = e instanceof Error ? e.message : String(e);
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Azure exposes the deployments a subscription has created, not OpenAI's
|
|
111
|
+
* catalogue: you address `my-gpt5-codex`, a name someone chose, and the
|
|
112
|
+
* underlying model is a property of it.
|
|
113
|
+
*/
|
|
114
|
+
async listAzureDeployments() {
|
|
115
|
+
const base = this.config.baseUrl.replace(/\/openai\/deployments\/[^/]+\/?$/, "");
|
|
116
|
+
const url = `${base}/openai/deployments?api-version=${AZURE_API_VERSION}`;
|
|
117
|
+
try {
|
|
118
|
+
const ctl = new AbortController();
|
|
119
|
+
const timer = setTimeout(() => ctl.abort(), 8000);
|
|
120
|
+
const res = await fetch(url, {
|
|
121
|
+
headers: { "api-key": this.config.apiKey ?? "" }, signal: ctl.signal,
|
|
122
|
+
});
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
if (!res.ok) {
|
|
125
|
+
lastListError = `HTTP ${res.status} from ${url}`;
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
const body = await res.json();
|
|
129
|
+
return (body.data ?? [])
|
|
130
|
+
.filter((d) => typeof d.id === "string")
|
|
131
|
+
// the deployment name is what you call; the model is what it runs
|
|
132
|
+
.map((d) => ({ id: d.id, name: d.model }));
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
lastListError = e instanceof Error ? e.message : String(e);
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
67
139
|
async listModels(signal) {
|
|
140
|
+
if (this.config.route === "bedrock")
|
|
141
|
+
return this.listBedrockModels();
|
|
142
|
+
if (this.config.route === "azure-openai")
|
|
143
|
+
return this.listAzureDeployments();
|
|
68
144
|
lastListError = undefined;
|
|
69
145
|
const anthropic = this.config.provider === "anthropic";
|
|
70
146
|
const url = anthropic
|
|
71
147
|
? `${this.config.baseUrl}/v1/models?limit=100`
|
|
72
148
|
: `${this.config.baseUrl}/models`;
|
|
149
|
+
// Sign when the route authenticates with AWS credentials. Sending an
|
|
150
|
+
// empty x-api-key here failed silently, which left setup with no models
|
|
151
|
+
// and a default that doesn't exist on Bedrock.
|
|
152
|
+
const auth = await this.authHeaders(url, "", "GET");
|
|
73
153
|
const headers = anthropic
|
|
74
|
-
? {
|
|
75
|
-
:
|
|
154
|
+
? { ...auth, "anthropic-version": "2023-06-01" }
|
|
155
|
+
: this.config.route === "azure-openai"
|
|
156
|
+
? { "api-key": this.config.apiKey ?? "" }
|
|
157
|
+
: (this.config.apiKey ? { authorization: `Bearer ${this.config.apiKey}` } : auth);
|
|
76
158
|
try {
|
|
77
159
|
const ctl = new AbortController();
|
|
78
160
|
const timer = setTimeout(() => ctl.abort(), 6000);
|
|
@@ -105,7 +187,14 @@ export class LLMClient {
|
|
|
105
187
|
* Auth for one request. An API key is a header; AWS credentials mean signing
|
|
106
188
|
* the whole request, which is how an IAM role authenticates with no key.
|
|
107
189
|
*/
|
|
108
|
-
async authHeaders(url, body) {
|
|
190
|
+
async authHeaders(url, body, method = "POST") {
|
|
191
|
+
// Foundry authenticates the Azure way: an api-key header, or a bearer
|
|
192
|
+
// token if one was minted from Entra ID.
|
|
193
|
+
if (this.config.route === "foundry" && this.config.apiKey) {
|
|
194
|
+
return /^ey[A-Za-z0-9_-]+\./.test(this.config.apiKey)
|
|
195
|
+
? { authorization: `Bearer ${this.config.apiKey}` } // an Entra token
|
|
196
|
+
: { "api-key": this.config.apiKey };
|
|
197
|
+
}
|
|
109
198
|
if (this.config.apiKey)
|
|
110
199
|
return { "x-api-key": this.config.apiKey };
|
|
111
200
|
if (this.config.route !== "bedrock")
|
|
@@ -118,7 +207,7 @@ export class LLMClient {
|
|
|
118
207
|
"or `aws configure`.");
|
|
119
208
|
}
|
|
120
209
|
return signRequest({
|
|
121
|
-
method
|
|
210
|
+
method,
|
|
122
211
|
url,
|
|
123
212
|
body,
|
|
124
213
|
region: this.config.region ?? "us-east-1",
|
|
@@ -256,8 +345,19 @@ export class LLMClient {
|
|
|
256
345
|
function: { name: t.name, description: t.description, parameters: t.input_schema },
|
|
257
346
|
}));
|
|
258
347
|
}
|
|
259
|
-
|
|
260
|
-
|
|
348
|
+
// Azure authenticates with api-key rather than a bearer token, and pins
|
|
349
|
+
// the API version as a query parameter.
|
|
350
|
+
const azure = this.config.route === "azure-openai";
|
|
351
|
+
// On Azure the deployment name goes in the path, and the model field in
|
|
352
|
+
// the body is ignored — the deployment decides which model runs.
|
|
353
|
+
const chatUrl = azure
|
|
354
|
+
? `${this.config.baseUrl}/deployments/${encodeURIComponent(modelOverride ?? this.config.model)}` +
|
|
355
|
+
`/chat/completions?api-version=${AZURE_API_VERSION}`
|
|
356
|
+
: `${this.config.baseUrl}/chat/completions`;
|
|
357
|
+
const res = await this.post(chatUrl, {
|
|
358
|
+
...(azure
|
|
359
|
+
? { "api-key": this.config.apiKey }
|
|
360
|
+
: { Authorization: `Bearer ${this.config.apiKey}` }),
|
|
261
361
|
"content-type": "application/json",
|
|
262
362
|
}, body, signal);
|
|
263
363
|
let text = "";
|
package/dist/onboard.js
CHANGED
|
@@ -10,6 +10,8 @@ const KEY_SOURCE = {
|
|
|
10
10
|
ANTHROPIC_API_KEY: "https://console.anthropic.com/settings/keys",
|
|
11
11
|
OPENAI_API_KEY: "https://platform.openai.com/api-keys",
|
|
12
12
|
BEDROCK_API_KEY: "the AWS console (Bedrock → API keys)",
|
|
13
|
+
AZURE_OPENAI_API_KEY: "the Azure portal (your OpenAI resource → Keys and Endpoint)",
|
|
14
|
+
AZURE_FOUNDRY_API_KEY: "the Azure portal (your Foundry resource → Keys and Endpoint)",
|
|
13
15
|
};
|
|
14
16
|
export function needsOnboarding() {
|
|
15
17
|
return !hasAnySettingsFile();
|
|
@@ -33,8 +35,12 @@ export async function setupComplete(cfg) {
|
|
|
33
35
|
if (!route.implemented) {
|
|
34
36
|
return { complete: false, missing: `${route.label} isn't wired up yet`, fix: "/route" };
|
|
35
37
|
}
|
|
36
|
-
|
|
38
|
+
// Not merely "is it set": an interrupted prompt used to store control
|
|
39
|
+
// characters, which are non-empty and so passed every naive check.
|
|
40
|
+
// eslint-disable-next-line no-control-regex
|
|
41
|
+
if (!cfg.model || !cfg.model.trim() || /[\x00-\x1f]/.test(cfg.model)) {
|
|
37
42
|
return { complete: false, missing: "no model chosen", fix: "/model" };
|
|
43
|
+
}
|
|
38
44
|
if (route.needsBaseUrl && !cfg.baseUrl) {
|
|
39
45
|
return { complete: false, missing: "no endpoint URL for this route", fix: "/route" };
|
|
40
46
|
}
|
|
@@ -87,6 +93,40 @@ async function readKey(rl, envName, promptText) {
|
|
|
87
93
|
}
|
|
88
94
|
return key;
|
|
89
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Read a plain answer, treating an interrupt as cancellation.
|
|
98
|
+
*
|
|
99
|
+
* A raw readline prompt collects Ctrl-C as the character \x03 rather than
|
|
100
|
+
* aborting, so pressing it during the region or model question stored control
|
|
101
|
+
* characters as the answer — and since they aren't empty, every completeness
|
|
102
|
+
* check passed and the profile was saved as valid.
|
|
103
|
+
*/
|
|
104
|
+
async function askText(rl, prompt) {
|
|
105
|
+
let raw;
|
|
106
|
+
try {
|
|
107
|
+
raw = await rl.question(prompt);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
// Ctrl-C is the only thing that means "stop". Everything else that isn't
|
|
113
|
+
// printable is terminal noise: a paste arrives wrapped in bracketed-paste
|
|
114
|
+
// markers, so rejecting all control characters made pasting an answer
|
|
115
|
+
// abort setup — which is exactly what someone does with a resource name.
|
|
116
|
+
// eslint-disable-next-line no-control-regex
|
|
117
|
+
if (/[\x03\x04]/.test(raw))
|
|
118
|
+
return undefined;
|
|
119
|
+
const cleaned = raw
|
|
120
|
+
// eslint-disable-next-line no-control-regex
|
|
121
|
+
.replace(/\x1b\[20[01]~/g, "")
|
|
122
|
+
// eslint-disable-next-line no-control-regex
|
|
123
|
+
.replace(/\x1b\[[0-9;]*[A-Za-z~]/g, "")
|
|
124
|
+
// eslint-disable-next-line no-control-regex
|
|
125
|
+
.replace(/[\x00-\x1f\x7f]/g, "");
|
|
126
|
+
return cleaned.trim();
|
|
127
|
+
}
|
|
128
|
+
/** Exposed for tests: paste handling here decides whether setup can finish. */
|
|
129
|
+
export const __askTextForTest = askText;
|
|
90
130
|
/**
|
|
91
131
|
* Walk vendor -> route -> region/endpoint -> model. Returns the profile it
|
|
92
132
|
* saved so the caller can report what still needs doing (e.g. an unset key).
|
|
@@ -181,13 +221,42 @@ export async function ensureCredential(rl, route, profile) {
|
|
|
181
221
|
return {};
|
|
182
222
|
}
|
|
183
223
|
if (route.id === "bedrock") {
|
|
184
|
-
//
|
|
224
|
+
// Two ways in, and which one you pick can matter for billing: an IAM role
|
|
225
|
+
// charges through the account that owns it, a Bedrock API key can belong
|
|
226
|
+
// to a different arrangement entirely. So ask rather than assume, even
|
|
227
|
+
// when one option is obvious from the environment.
|
|
185
228
|
const { discoverAwsCredentials } = await import("./sigv4.js");
|
|
186
229
|
const aws = await discoverAwsCredentials();
|
|
187
230
|
console.log();
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
231
|
+
const how = await select(rl, "How should Faber authenticate with AWS?", [
|
|
232
|
+
`AWS credentials (IAM role) ${pc.dim(aws ? `found: ${aws.source}` : "none found here")}`,
|
|
233
|
+
`Bedrock API key ${pc.dim("billed through that key instead")}`,
|
|
234
|
+
]);
|
|
235
|
+
if (how === 0) {
|
|
236
|
+
if (!aws) {
|
|
237
|
+
// Nothing to sign with, so setup did not succeed and saves nothing.
|
|
238
|
+
console.log(pc.yellow(" No AWS credentials on this machine."));
|
|
239
|
+
console.log(pc.dim(" Run where an IAM role is available (SageMaker, EC2, ECS), or `aws configure`."));
|
|
240
|
+
return { switchedTo: "quit" };
|
|
241
|
+
}
|
|
242
|
+
console.log(pc.dim(` Signing automatically with your AWS role. No key needed.`));
|
|
243
|
+
delete profile.apiKeyEnv; // this route authenticates by signing
|
|
244
|
+
return {};
|
|
245
|
+
}
|
|
246
|
+
console.log();
|
|
247
|
+
console.log("Faber needs a Bedrock API key.");
|
|
248
|
+
const where = KEY_SOURCE[route.keyEnv];
|
|
249
|
+
if (where)
|
|
250
|
+
console.log(pc.dim(` Get one at ${where}`));
|
|
251
|
+
const key = await readKey(rl, route.keyEnv, ` ${route.keyEnv} (hidden): `);
|
|
252
|
+
if (!key) {
|
|
253
|
+
console.log(pc.yellow(" No key entered — setup not completed, nothing saved."));
|
|
254
|
+
return { switchedTo: "quit" };
|
|
255
|
+
}
|
|
256
|
+
saveCredential(route.keyEnv, key);
|
|
257
|
+
profile.apiKeyEnv = route.keyEnv;
|
|
258
|
+
profile.preferStoredKey = true; // an explicit choice beats a role
|
|
259
|
+
console.log(pc.dim(` saved (${maskCredential(key)}) — kept on this computer only`));
|
|
191
260
|
return {};
|
|
192
261
|
}
|
|
193
262
|
// No key anywhere: say where to get one, read it hidden, and if they don't
|
|
@@ -256,15 +325,6 @@ async function discoverModels(route, profile) {
|
|
|
256
325
|
async function finish(rl, route, base) {
|
|
257
326
|
const profile = { ...base, route: route.id };
|
|
258
327
|
const activeRoute = route;
|
|
259
|
-
if (route.needsRegion) {
|
|
260
|
-
const ans = (await rl.question(`AWS region [${DEFAULT_REGION}]: `)).trim();
|
|
261
|
-
profile.region = ans || DEFAULT_REGION;
|
|
262
|
-
}
|
|
263
|
-
if (route.needsBaseUrl) {
|
|
264
|
-
const ans = (await rl.question("Base URL of the OpenAI-compatible endpoint: ")).trim();
|
|
265
|
-
if (ans)
|
|
266
|
-
profile.baseUrl = ans;
|
|
267
|
-
}
|
|
268
328
|
if (route.keyEnv) {
|
|
269
329
|
profile.apiKeyEnv = route.keyEnv;
|
|
270
330
|
const outcome = await ensureCredential(rl, activeRoute, profile);
|
|
@@ -274,6 +334,51 @@ async function finish(rl, route, base) {
|
|
|
274
334
|
return { profile, route: activeRoute, aborted: true };
|
|
275
335
|
}
|
|
276
336
|
}
|
|
337
|
+
if (route.id === "foundry") {
|
|
338
|
+
// Same question as Azure OpenAI: the resource name is what people know.
|
|
339
|
+
const res = await askText(rl, "Azure resource name (from your endpoint URL): ");
|
|
340
|
+
if (res === undefined)
|
|
341
|
+
return { profile, route: activeRoute, aborted: true };
|
|
342
|
+
if (res) {
|
|
343
|
+
profile.baseUrl = /^https?:\/\//.test(res)
|
|
344
|
+
? res.replace(/\/+$/, "")
|
|
345
|
+
: `https://${res}.services.ai.azure.com/anthropic`;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
else if (route.id === "azure-openai") {
|
|
349
|
+
// Ask for the resource name rather than a URL: it's the part people know
|
|
350
|
+
// from the portal, and the rest of the endpoint is fixed.
|
|
351
|
+
const res = await askText(rl, "Azure resource name (from your endpoint URL): ");
|
|
352
|
+
if (res === undefined)
|
|
353
|
+
return { profile, route: activeRoute, aborted: true };
|
|
354
|
+
if (res) {
|
|
355
|
+
profile.baseUrl = /^https?:\/\//.test(res)
|
|
356
|
+
? res.replace(/\/+$/, "")
|
|
357
|
+
: `https://${res}.openai.azure.com/openai`;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
else if (route.needsBaseUrl) {
|
|
361
|
+
const ans = await askText(rl, "Base URL of the OpenAI-compatible endpoint: ");
|
|
362
|
+
if (ans === undefined)
|
|
363
|
+
return { profile, route: activeRoute, aborted: true };
|
|
364
|
+
if (ans)
|
|
365
|
+
profile.baseUrl = ans;
|
|
366
|
+
}
|
|
367
|
+
if (route.needsRegion) {
|
|
368
|
+
// Only ask when the environment hasn't already answered.
|
|
369
|
+
const { discoverAwsRegion } = await import("./sigv4.js");
|
|
370
|
+
const found = discoverAwsRegion();
|
|
371
|
+
if (found) {
|
|
372
|
+
profile.region = found.region;
|
|
373
|
+
console.log(pc.dim(` Region ${found.region} (from ${found.source}) · /route to change`));
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
const ans = await askText(rl, `AWS region [${DEFAULT_REGION}]: `);
|
|
377
|
+
if (ans === undefined)
|
|
378
|
+
return { profile, route: activeRoute, aborted: true };
|
|
379
|
+
profile.region = ans || DEFAULT_REGION;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
277
382
|
// Model choice, with real ids and real prices.
|
|
278
383
|
//
|
|
279
384
|
// Aliases like "gpt" or "sonnet" hide what you're actually paying for, and
|
|
@@ -329,14 +434,38 @@ async function finish(rl, route, base) {
|
|
|
329
434
|
}
|
|
330
435
|
else {
|
|
331
436
|
const hint = activeRoute.id === "ollama" ? "qwen2.5-coder" : "";
|
|
332
|
-
const ans =
|
|
437
|
+
const ans = await askText(rl, `Model id${hint ? ` [${hint}]` : ""}: `);
|
|
438
|
+
if (ans === undefined)
|
|
439
|
+
return { profile, route: activeRoute, aborted: true };
|
|
333
440
|
profile.model = ans || hint || undefined;
|
|
334
441
|
}
|
|
442
|
+
// Nothing is written unless the result is genuinely usable. Every earlier
|
|
443
|
+
// exit already returns aborted, but this is the single place that decides,
|
|
444
|
+
// so a future step can't accidentally save a half-finished profile.
|
|
445
|
+
const check = await setupComplete({
|
|
446
|
+
route: profile.route,
|
|
447
|
+
model: profile.model,
|
|
448
|
+
baseUrl: profile.baseUrl ?? baseUrlFor(activeRoute, profile.region),
|
|
449
|
+
apiKey: activeRoute.keyEnv ? credentialSource(activeRoute.keyEnv)?.value : undefined,
|
|
450
|
+
});
|
|
451
|
+
if (!check.complete) {
|
|
452
|
+
console.log();
|
|
453
|
+
console.log(pc.yellow(`Setup not completed — ${check.missing}.`));
|
|
454
|
+
console.log(pc.dim(" Nothing was saved. Run faber again to start over."));
|
|
455
|
+
return { profile, route: activeRoute, aborted: true };
|
|
456
|
+
}
|
|
335
457
|
const settings = loadSettings();
|
|
336
458
|
settings.profiles[settings.activeProfile] = profile;
|
|
337
459
|
saveSettings(settings);
|
|
338
|
-
|
|
460
|
+
// Bedrock signs with an IAM role when one is available, so a missing key is
|
|
461
|
+
// not "one thing left" — saying so contradicts the line printed moments ago.
|
|
462
|
+
let missingKeyEnv = activeRoute.keyEnv && !credentialSource(activeRoute.keyEnv)
|
|
339
463
|
? activeRoute.keyEnv : undefined;
|
|
464
|
+
if (missingKeyEnv && activeRoute.id === "bedrock") {
|
|
465
|
+
const { discoverAwsCredentials } = await import("./sigv4.js");
|
|
466
|
+
if (await discoverAwsCredentials())
|
|
467
|
+
missingKeyEnv = undefined;
|
|
468
|
+
}
|
|
340
469
|
return { profile, route: activeRoute, missingKeyEnv };
|
|
341
470
|
}
|
|
342
471
|
/** Summary printed after onboarding, including anything still to be done. */
|
package/dist/pricing.js
CHANGED
|
@@ -253,6 +253,21 @@ export async function refreshPrices(url = DATASET_URL, opts = {}) {
|
|
|
253
253
|
* id, then normalized), then the built-in table. Undefined means "unknown",
|
|
254
254
|
* which the ledger renders as — rather than guessing.
|
|
255
255
|
*/
|
|
256
|
+
/**
|
|
257
|
+
* Azure's US Data Zone deployments bill at 1.1x. Applying it keeps the ledger
|
|
258
|
+
* honest for teams who chose that deployment for data-residency reasons —
|
|
259
|
+
* under-reporting spend is the one direction a cost tool must not err in.
|
|
260
|
+
*/
|
|
261
|
+
export const US_DATA_ZONE_MULTIPLIER = 1.1;
|
|
262
|
+
export function scalePrice(p, factor) {
|
|
263
|
+
const r = (n) => Math.round(n * factor * 1e6) / 1e6;
|
|
264
|
+
return {
|
|
265
|
+
...p,
|
|
266
|
+
in: r(p.in), out: r(p.out),
|
|
267
|
+
cacheRead: p.cacheRead === undefined ? undefined : r(p.cacheRead),
|
|
268
|
+
cacheWrite: p.cacheWrite === undefined ? undefined : r(p.cacheWrite),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
256
271
|
export function priceFor(modelId, override) {
|
|
257
272
|
if (override?.in && override?.out)
|
|
258
273
|
return { in: override.in, out: override.out };
|
package/dist/prompt.js
CHANGED
|
@@ -71,6 +71,14 @@ async function selectRaw(rl, question, options, defaultIndex = 0) {
|
|
|
71
71
|
stdin.removeListener("data", onData);
|
|
72
72
|
stdin.setRawMode(wasRaw);
|
|
73
73
|
guardFn?.(false);
|
|
74
|
+
// Drop anything still buffered — typically the Enter that confirmed this
|
|
75
|
+
// menu. Left in place, readline hands it straight to the next question,
|
|
76
|
+
// which then returns an empty answer before the user can type a
|
|
77
|
+
// character: the prompt appears and vanishes in the same instant.
|
|
78
|
+
try {
|
|
79
|
+
while (stdin.read() !== null) { /* discard */ }
|
|
80
|
+
}
|
|
81
|
+
catch { /* not readable */ }
|
|
74
82
|
rl.resume();
|
|
75
83
|
resolve(result);
|
|
76
84
|
};
|
package/dist/routes.js
CHANGED
|
@@ -39,6 +39,24 @@ export const ROUTES = [
|
|
|
39
39
|
aliasesArePinned: true, // Bedrock model ids differ per region/deployment
|
|
40
40
|
implemented: true,
|
|
41
41
|
},
|
|
42
|
+
{
|
|
43
|
+
// Claude hosted on Azure through Microsoft Foundry. It speaks the same
|
|
44
|
+
// Messages API as the direct route, so this is a base URL and an auth
|
|
45
|
+
// header rather than a new wire — the migration Microsoft describes as
|
|
46
|
+
// "swap the base URL and authentication, keep the calls the same".
|
|
47
|
+
id: "foundry",
|
|
48
|
+
vendor: "Anthropic",
|
|
49
|
+
label: "Microsoft Foundry (Azure)",
|
|
50
|
+
hint: "your Azure subscription owns auth and billing",
|
|
51
|
+
wire: "anthropic",
|
|
52
|
+
needsBaseUrl: true,
|
|
53
|
+
// Its own variable: a Foundry resource is separate from an Azure OpenAI
|
|
54
|
+
// one, with its own key. Sharing the name meant a key saved for GPT
|
|
55
|
+
// deployments was offered for Claude, where it cannot work.
|
|
56
|
+
keyEnv: "AZURE_FOUNDRY_API_KEY",
|
|
57
|
+
aliasesArePinned: true, // you address a deployment someone named
|
|
58
|
+
implemented: true,
|
|
59
|
+
},
|
|
42
60
|
{
|
|
43
61
|
id: "vertex",
|
|
44
62
|
vendor: "Anthropic",
|
|
@@ -48,6 +66,21 @@ export const ROUTES = [
|
|
|
48
66
|
aliasesArePinned: true,
|
|
49
67
|
implemented: false, // needs Google OAuth
|
|
50
68
|
},
|
|
69
|
+
{
|
|
70
|
+
// Azure hosts OpenAI's models under a company's own subscription, which is
|
|
71
|
+
// the answer for someone who wants codex billed through work rather than a
|
|
72
|
+
// personal card. Same wire as OpenAI, but the key travels in its own
|
|
73
|
+
// header and the deployment name lives in the URL.
|
|
74
|
+
id: "azure-openai",
|
|
75
|
+
vendor: "OpenAI",
|
|
76
|
+
label: "Azure OpenAI",
|
|
77
|
+
hint: "your Azure subscription owns auth and billing",
|
|
78
|
+
wire: "openai",
|
|
79
|
+
needsBaseUrl: true,
|
|
80
|
+
keyEnv: "AZURE_OPENAI_API_KEY",
|
|
81
|
+
aliasesArePinned: true, // you address deployments, not model ids
|
|
82
|
+
implemented: true,
|
|
83
|
+
},
|
|
51
84
|
{
|
|
52
85
|
id: "openai-api",
|
|
53
86
|
vendor: "OpenAI",
|
package/dist/sigv4.js
CHANGED
|
@@ -93,7 +93,7 @@ export async function discoverAwsCredentials(profileName = process.env.AWS_PROFI
|
|
|
93
93
|
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
|
94
94
|
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
|
95
95
|
sessionToken: process.env.AWS_SESSION_TOKEN,
|
|
96
|
-
source: "environment",
|
|
96
|
+
source: "your environment",
|
|
97
97
|
};
|
|
98
98
|
}
|
|
99
99
|
// 2. container credential endpoint (ECS, SageMaker, CodeBuild)
|
|
@@ -130,7 +130,7 @@ async function fetchContainerCredentials(url) {
|
|
|
130
130
|
accessKeyId: b.AccessKeyId,
|
|
131
131
|
secretAccessKey: b.SecretAccessKey,
|
|
132
132
|
sessionToken: b.Token,
|
|
133
|
-
source: "
|
|
133
|
+
source: "this environment's role",
|
|
134
134
|
};
|
|
135
135
|
}
|
|
136
136
|
catch {
|
|
@@ -175,3 +175,51 @@ export function readSharedCredentials(profileName = "default", file = path.join(
|
|
|
175
175
|
source: `~/.aws/credentials [${wanted}]`,
|
|
176
176
|
};
|
|
177
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Find the AWS region the way the SDKs do.
|
|
180
|
+
*
|
|
181
|
+
* Credentials don't carry a region, but the endpoint and the signature both
|
|
182
|
+
* need one — so it has to come from somewhere. In SageMaker, Lambda and ECS
|
|
183
|
+
* it's already in the environment, and on a laptop it's usually in the config
|
|
184
|
+
* file, which means asking is normally an unnecessary question.
|
|
185
|
+
*/
|
|
186
|
+
export function discoverAwsRegion(profileName = process.env.AWS_PROFILE ?? "default", file = path.join(os.homedir(), ".aws", "config")) {
|
|
187
|
+
const env = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION;
|
|
188
|
+
if (env) {
|
|
189
|
+
return {
|
|
190
|
+
region: env,
|
|
191
|
+
source: process.env.AWS_REGION ? "AWS_REGION" : "AWS_DEFAULT_REGION",
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
// ~/.aws/config uses "[profile name]" for everything except default
|
|
195
|
+
let text;
|
|
196
|
+
try {
|
|
197
|
+
text = fs.readFileSync(file, "utf8");
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
const wanted = profileName === "default" ? "default" : `profile ${profileName}`;
|
|
203
|
+
let current = "";
|
|
204
|
+
for (const raw of text.split("\n")) {
|
|
205
|
+
const line = raw.split(/[#;]/)[0].trim();
|
|
206
|
+
if (!line)
|
|
207
|
+
continue;
|
|
208
|
+
const header = /^\[(.+)\]$/.exec(line);
|
|
209
|
+
if (header) {
|
|
210
|
+
current = header[1].trim();
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (current !== wanted)
|
|
214
|
+
continue;
|
|
215
|
+
const eq = line.indexOf("=");
|
|
216
|
+
if (eq === -1)
|
|
217
|
+
continue;
|
|
218
|
+
if (line.slice(0, eq).trim().toLowerCase() === "region") {
|
|
219
|
+
const region = line.slice(eq + 1).trim();
|
|
220
|
+
if (region)
|
|
221
|
+
return { region, source: `~/.aws/config [${profileName}]` };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberwright",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
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",
|