aegis-desktop 0.7.7 → 0.7.8
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 +103 -17
- package/lib/local/endpoints.js +228 -0
- package/lib/local/engine.js +138 -18
- package/lib/settings.js +42 -1
- package/package.json +4 -3
- package/renderer/app.js +179 -63
- package/renderer/index.html +1 -0
- package/renderer/preset-fill.js +79 -0
- package/renderer/stream-policy.js +65 -1
- package/renderer/transcript-view.js +5 -0
- package/renderer/usage.js +42 -11
- package/vendor/aegis.js +21 -3
package/README.md
CHANGED
|
@@ -5,26 +5,95 @@ with an **agentic tool loop**: the model can read, write, and edit files,
|
|
|
5
5
|
list directories, glob, grep, run shell commands in a persistent session, and
|
|
6
6
|
delegate whole sub-tasks to subagents. It does **not** require Claude Code.
|
|
7
7
|
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
Download a build from the
|
|
11
|
+
[releases page](https://github.com/aegiscloud/aegiscode-desktop/releases), or
|
|
12
|
+
install from npm:
|
|
13
|
+
|
|
8
14
|
```bash
|
|
9
|
-
npm install -g aegis-desktop
|
|
10
|
-
aegis
|
|
15
|
+
npm install -g aegis-desktop # requires Node 18+
|
|
16
|
+
aegis # launch
|
|
11
17
|
```
|
|
12
18
|
|
|
19
|
+
Either way, on first launch:
|
|
20
|
+
|
|
21
|
+
1. Open **Settings** in the sidebar.
|
|
22
|
+
2. Paste an AEGIS key into the **API key** row — get a free one at
|
|
23
|
+
<https://aegiscloud.org>. `AEGIS_API_KEY` is picked up from the environment
|
|
24
|
+
if you would rather not paste it.
|
|
25
|
+
3. Pick a model class from the picker at the bottom of the composer.
|
|
26
|
+
|
|
27
|
+
The published package is [`aegis-desktop`](https://www.npmjs.com/package/aegis-desktop)
|
|
28
|
+
on npm; this repo is its source. No AEGIS account is needed for the **Ollama**
|
|
29
|
+
class or a **local** custom endpoint — every other class bills your AEGIS
|
|
30
|
+
account, either the pooled margin or the BYOK handling fee.
|
|
31
|
+
|
|
32
|
+
## Using it
|
|
33
|
+
|
|
34
|
+
Everything is in the window — there is no slash-command line to learn. Plain
|
|
35
|
+
text in the composer is a prompt.
|
|
36
|
+
|
|
37
|
+
| Where | What it does |
|
|
38
|
+
|---|---|
|
|
39
|
+
| **Composer** | Type and press `Enter`. `Shift+Enter` for a newline. |
|
|
40
|
+
| **Class picker** | Bottom of the composer — switches the route mid-conversation, context intact. |
|
|
41
|
+
| **Model dropdown** | Next to it — pins a model id for the selected class, or leaves it on "server default (auto)". |
|
|
42
|
+
| **Settings** (sidebar) | API key, provider keys, tool-confirmation toggle. |
|
|
43
|
+
| **Queue card** (sidebar) | The unattended work queue — same file the CLI drains. |
|
|
44
|
+
| **Quick Launcher card** | Enable/rebind the global hotkey. |
|
|
45
|
+
| **remember** (on any reply) | Pin that message to cross-machine cloud memory. |
|
|
46
|
+
|
|
47
|
+
Approval cards appear inline before `exec`, `writeFile` or `editFile` runs:
|
|
48
|
+
**Allow once**, **Allow for this session**, or **Deny**. Turn them off entirely
|
|
49
|
+
with **Settings → "Confirm before running tools"** — on by default.
|
|
50
|
+
|
|
13
51
|
## Model classes
|
|
14
52
|
|
|
15
|
-
Pick any of
|
|
53
|
+
Pick any of five transports from the model-class picker, switchable
|
|
16
54
|
mid-conversation with context intact:
|
|
17
55
|
|
|
18
|
-
| Class | Transport | Key held in |
|
|
19
|
-
|
|
20
|
-
| **Aegis Cloud** | `aegiscloud.org` — one entry, **Nexus**; the pool auto-routes across whichever providers are live | main process |
|
|
21
|
-
| **Ollama** | local `ollama` daemon | no key needed |
|
|
22
|
-
| **Custom OpenAI-compatible** (LM Studio,
|
|
23
|
-
| **Anthropic-compatible** (
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
56
|
+
| Class | Transport | Key held in | Billed? |
|
|
57
|
+
|---|---|---|---|
|
|
58
|
+
| **Aegis Cloud** | `aegiscloud.org` — one entry, **Nexus**; the pool auto-routes across whichever providers are live | main process | yes — pooled margin |
|
|
59
|
+
| **Ollama** | local `ollama` daemon | no key needed | **no** — nothing leaves the machine |
|
|
60
|
+
| **Custom OpenAI-compatible** (LM Studio, vLLM, llama.cpp, an Ollama shim) | direct from the desktop app, **local base URLs only** | main process — never sent to the renderer | **no** — free lane, local only |
|
|
61
|
+
| **Anthropic-compatible** (a local Messages-format gateway — LiteLLM, claude-code-router) | direct from the desktop app, **local base URLs only** | main process | **no** — free lane, local only |
|
|
62
|
+
| **Bring your own key** | your provider key, relayed by AEGIS — see below | main process | yes — flat AEGIS handling fee |
|
|
63
|
+
|
|
64
|
+
Get a free AEGIS key at **https://aegiscloud.org**. The two custom classes are
|
|
65
|
+
free because aegiscode talks straight to an endpoint on your own machine; point
|
|
66
|
+
one at a hosted provider instead and there is nothing for AEGIS to meter, so
|
|
67
|
+
those models live on the **Bring your own key** class, which does bill.
|
|
68
|
+
|
|
69
|
+
### Bring your own key (BYOK)
|
|
70
|
+
|
|
71
|
+
For the providers AEGIS does **not** run in its pool — bring your own key and
|
|
72
|
+
the models that key unlocks appear as their own entries, one per provider.
|
|
73
|
+
|
|
74
|
+
1. **Settings** → find the row named `BYOK: <Provider>` (OpenAI, Anthropic,
|
|
75
|
+
DeepSeek, Groq, xAI, Mistral, Gemini, OpenRouter, …).
|
|
76
|
+
2. Paste your provider key and **Save**. There is no base-URL field on these
|
|
77
|
+
rows: a BYOK turn always talks to AEGIS's own relay
|
|
78
|
+
(`/api/v1/byok/chat/completions`), which is what attaches your AEGIS key.
|
|
79
|
+
3. Select the **Bring your own key** class and pick a model.
|
|
80
|
+
|
|
81
|
+
**What it costs.** AEGIS pays your provider nothing on this lane, so there is no
|
|
82
|
+
provider cost to take a margin on — instead your AEGIS account is charged a flat
|
|
83
|
+
**handling fee** per 1k tokens, for the routing, prompt assembly, caching, tool
|
|
84
|
+
bridging and uptime that still happen server-side. The rate is the server's own
|
|
85
|
+
(published on `GET /api/v1/byok/providers`) and is shown in Settings directly
|
|
86
|
+
under the provider rows; it is never hardcoded here, so it cannot drift from the
|
|
87
|
+
ledger that bills you. It is deliberately below the pooled price for the same
|
|
88
|
+
traffic — BYOK stays the cheaper lane, it just is not the free one.
|
|
89
|
+
|
|
90
|
+
**Two keys are needed.** Your provider key *and* an AEGIS account key: the
|
|
91
|
+
handling fee has to be billed somewhere. With no AEGIS key connected the class
|
|
92
|
+
shows every model but says exactly that, rather than failing opaquely.
|
|
93
|
+
|
|
94
|
+
Note that a **BYOK turn is single-shot** — the relay takes no `tools` parameter,
|
|
95
|
+
so the agentic tool loop below is off for these models. Use a pooled or direct
|
|
96
|
+
class when you want file and shell access.
|
|
28
97
|
|
|
29
98
|
## Tools available to the model
|
|
30
99
|
|
|
@@ -180,7 +249,16 @@ The app registers an `aegis://` protocol handler:
|
|
|
180
249
|
## Run from source
|
|
181
250
|
|
|
182
251
|
```bash
|
|
183
|
-
|
|
252
|
+
git clone https://github.com/aegiscloud/aegiscode-desktop.git
|
|
253
|
+
cd aegiscode-desktop
|
|
254
|
+
npm install
|
|
255
|
+
npm start
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Or, from the monorepo checkout, run this directory directly:
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
cd aegiscode-plugin/desktop
|
|
184
262
|
npm install
|
|
185
263
|
npm start
|
|
186
264
|
```
|
|
@@ -217,6 +295,14 @@ bin/aegis.js `aegis` CLI entry point for the global npm install
|
|
|
217
295
|
This directory is part of the [aegiscode-plugin](../README.md) monorepo,
|
|
218
296
|
which also ships a Claude Code plugin and the shared `client/aegis.js`
|
|
219
297
|
transport over the same AEGIS backend — see the repo root for that fuller
|
|
220
|
-
architecture picture
|
|
221
|
-
|
|
222
|
-
|
|
298
|
+
architecture picture, and [cli/README.md](../cli/README.md) for the terminal
|
|
299
|
+
host over the same engine.
|
|
300
|
+
|
|
301
|
+
**Two repos, one product.** This directory is the source of truth. The
|
|
302
|
+
standalone repo at
|
|
303
|
+
[aegiscloud/aegiscode-desktop](https://github.com/aegiscloud/aegiscode-desktop)
|
|
304
|
+
is a `git subtree split` of it — same code, published separately so it can be
|
|
305
|
+
cloned and built on its own. Edits land here and are synced out; nothing is
|
|
306
|
+
authored there. The npm package
|
|
307
|
+
[`aegis-desktop`](https://www.npmjs.com/package/aegis-desktop) is built from
|
|
308
|
+
that standalone repo.
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The direct-dial policy: which base URLs may reach a provider transport, and
|
|
5
|
+
* why that is the only free lane left.
|
|
6
|
+
*
|
|
7
|
+
* Both hosts that can talk to a user-supplied endpoint — the desktop app
|
|
8
|
+
* (settings pane → 'openai-compat' / 'anthropic' classes) and the CLI
|
|
9
|
+
* (src/engine.js prepareCustom, the `/model add` catalog) — share THIS file
|
|
10
|
+
* rather than a copy each. The CLI vendors `desktop/lib/local/` wholesale and
|
|
11
|
+
* resolves this module through src/sharedpaths.js, so the two hosts cannot
|
|
12
|
+
* drift apart about what "local" means or which error a remote URL gets. A
|
|
13
|
+
* second implementation is how one host would keep billing correctly while the
|
|
14
|
+
* other quietly served unpaid traffic.
|
|
15
|
+
*
|
|
16
|
+
* The rule: every lane that can bill does. `aegis` is the pooled route (the
|
|
17
|
+
* account key is attached and the pool takes its margin); `byok` is the relay
|
|
18
|
+
* (`services/pricing.price_byok_call` → `token_bank.charge_byok`, the AEGIS
|
|
19
|
+
* handling fee, charged against the account resolved from `X-AEGIS-Key`). The
|
|
20
|
+
* provider transports in providers.js bill NOTHING — they are a direct dial to
|
|
21
|
+
* whoever owns the URL — so the only usage they may carry is an endpoint on
|
|
22
|
+
* this machine, where there is no vendor to pay in the first place.
|
|
23
|
+
*
|
|
24
|
+
* That is why a remote base URL is refused rather than metered: the relay
|
|
25
|
+
* accepts a fixed catalog of provider ids (`services/nexus_provider/catalog.py`,
|
|
26
|
+
* upstream key read from `X-Provider-Key`), so an arbitrary remote URL has
|
|
27
|
+
* nothing to be billed against even if a client wanted to invoice it. Remote
|
|
28
|
+
* providers belong on BYOK, which bills. There is deliberately no flag or env
|
|
29
|
+
* var that re-opens the direct lane for a remote URL, because such a flag would
|
|
30
|
+
* be a billing bypass.
|
|
31
|
+
*
|
|
32
|
+
* Where the gate lives — the seams, not the transport:
|
|
33
|
+
* · desktop/lib/settings.js `set()` refuses to STORE a remote base URL, so
|
|
34
|
+
* the unusable configuration cannot be created.
|
|
35
|
+
* · desktop/lib/local/engine.js `chat()` refuses to DIAL one before any
|
|
36
|
+
* transport call (a row hand-edited into the
|
|
37
|
+
* settings file, or written before this policy
|
|
38
|
+
* existed, still cannot be used).
|
|
39
|
+
* · cli/src/custommodels.js refuses one at `/model add` and again at
|
|
40
|
+
* dispatch, for the same two reasons.
|
|
41
|
+
* providers.js itself stays policy-free: it is a wire-format function, and
|
|
42
|
+
* pinning the policy there would make it unreachable for the tests that check
|
|
43
|
+
* URL building. Same split the BYOK balance gate uses — the relay owns the
|
|
44
|
+
* decision, the client refuses to send.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/** Host of a URL, lower-cased, or '' if it does not parse. */
|
|
48
|
+
function hostOf(url) {
|
|
49
|
+
try {
|
|
50
|
+
return new URL(String(url)).host.toLowerCase();
|
|
51
|
+
} catch {
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether a base URL addresses an endpoint on this machine — the gate that
|
|
58
|
+
* decides whether the free direct lane is available at all.
|
|
59
|
+
*
|
|
60
|
+
* True for: loopback (localhost, `*.localhost`, 127.0.0.0/8, `::1`), RFC1918
|
|
61
|
+
* private ranges (10/8, 172.16/12, 192.168/16), IPv6 unique-local fc00::/7,
|
|
62
|
+
* link-local (169.254/16, fe80::/10), the reserved local suffixes `.local`,
|
|
63
|
+
* `.internal`, `.lan`, and a bare dotless hostname (`ollama`, `gpu-box` — it
|
|
64
|
+
* can only resolve through this machine's own resolver).
|
|
65
|
+
*
|
|
66
|
+
* FAIL CLOSED: anything that does not parse, carries no host, is a public
|
|
67
|
+
* address or name, or is merely ambiguous is NOT local. That direction is the
|
|
68
|
+
* whole point — a false "local" is an unpaid turn, a false "remote" is a
|
|
69
|
+
* refusal the user can fix by pointing at a local address or by moving to the
|
|
70
|
+
* billed lane, which is the outcome we want in a tie.
|
|
71
|
+
*/
|
|
72
|
+
function isLocalEndpoint(url) {
|
|
73
|
+
let host = '';
|
|
74
|
+
let scheme = '';
|
|
75
|
+
try {
|
|
76
|
+
const parsed = new URL(String(url == null ? '' : url));
|
|
77
|
+
host = parsed.hostname.toLowerCase();
|
|
78
|
+
scheme = parsed.protocol.toLowerCase();
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
// Only a base URL a transport can actually dial. The transports in
|
|
83
|
+
// providers.js append '/chat/completions' and fetch it, so anything that is
|
|
84
|
+
// not http(s) — or has no scheme at all — is not an endpoint, and is refused
|
|
85
|
+
// by the same fail-closed rule as an unparseable value. Checked before the
|
|
86
|
+
// host, so `ftp://box.local` cannot ride the local suffix to a pass.
|
|
87
|
+
if (scheme !== 'http:' && scheme !== 'https:') return false;
|
|
88
|
+
if (!host) return false;
|
|
89
|
+
// WHATWG keeps the brackets on an IPv6 hostname; strip them so one spelling
|
|
90
|
+
// covers both `[::1]` and a bare `::1`.
|
|
91
|
+
if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
|
|
92
|
+
if (!host || /[\s/\\@]/.test(host)) return false;
|
|
93
|
+
|
|
94
|
+
// Loopback by name, plus the reserved local suffixes. `.local`/`.internal`/
|
|
95
|
+
// `.lan` are the mDNS / split-DNS names an on-box service answers to. The
|
|
96
|
+
// suffix must END the name: `example.local.evil.com` is somebody else's host.
|
|
97
|
+
if (host === 'localhost' || host.endsWith('.localhost')) return true;
|
|
98
|
+
if (/\.(local|internal|lan)$/.test(host)) return true;
|
|
99
|
+
|
|
100
|
+
// IPv6 literals.
|
|
101
|
+
if (host.includes(':')) {
|
|
102
|
+
if (host === '::1' || host === '0:0:0:0:0:0:0:1') return true; // loopback
|
|
103
|
+
if (/^f[cd][0-9a-f]{2}:/.test(host)) return true; // fc00::/7 unique-local
|
|
104
|
+
if (/^fe[89ab][0-9a-f]:/.test(host)) return true; // fe80::/10 link-local
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// IPv4 literals.
|
|
109
|
+
const quad = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
110
|
+
if (quad) {
|
|
111
|
+
const o = quad.slice(1).map(Number);
|
|
112
|
+
if (o.some((n) => n > 255)) return false; // not an address, and not local
|
|
113
|
+
const [a, b] = o;
|
|
114
|
+
if (a === 127) return true; // 127.0.0.0/8 loopback
|
|
115
|
+
if (a === 10) return true; // 10.0.0.0/8
|
|
116
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
|
|
117
|
+
if (a === 192 && b === 168) return true; // 192.168.0.0/16
|
|
118
|
+
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local
|
|
119
|
+
return false; // a real, routable address — remote, and billed
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// A bare, dotless hostname is local by convention (`ollama`, `llama-box`):
|
|
123
|
+
// it can only resolve through this machine's own resolver or /etc/hosts.
|
|
124
|
+
// Anything with a dot is a DNS name for somebody else's machine.
|
|
125
|
+
return !host.includes('.');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** What IS allowed here, in one sentence, shared by both hosts' refusals. */
|
|
129
|
+
const ALLOWED_HINT =
|
|
130
|
+
'Allowed: localhost, 127.0.0.1, a private/LAN address (10.x, 172.16-31.x, 192.168.x), ' +
|
|
131
|
+
'*.local/.internal/.lan, or a dotless host like "ollama".';
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The refusal a non-local base URL gets, in the words the user needs in order
|
|
135
|
+
* to act: it names the offending URL, the kind of address that IS allowed, and
|
|
136
|
+
* the lane that replaces this one. `hint` is the host-specific half —
|
|
137
|
+
* `/class byok` in the terminal, the Bring-your-own-key Settings row in the
|
|
138
|
+
* GUI — while the policy half is shared, so the two hosts cannot describe the
|
|
139
|
+
* same rule differently.
|
|
140
|
+
*/
|
|
141
|
+
function remoteRefusal(baseURL, { subject = 'custom endpoints', hint = '' } = {}) {
|
|
142
|
+
const where = String(baseURL == null ? '' : baseURL);
|
|
143
|
+
const tail = hint || 'Remote providers are billed, so use the lane that bills them, or point this at a local address.';
|
|
144
|
+
return `${subject} must be LOCAL — remote base URL ${JSON.stringify(where)} is not offered on this lane. ` +
|
|
145
|
+
`${ALLOWED_HINT} ${tail}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Throw the refusal (as a 400-class error every caller already paints) unless
|
|
150
|
+
* the base URL is local. The one-line form of the gate for the dispatch and
|
|
151
|
+
* storage seams, so neither has to remember the status code.
|
|
152
|
+
*
|
|
153
|
+
* An EMPTY base URL is refused here like any other non-local value, because
|
|
154
|
+
* there is no endpoint to dial. The storage seam does not use this function
|
|
155
|
+
* directly for that reason — see allowsDirectDialRow below, which treats "no
|
|
156
|
+
* URL configured" as "nothing to gate".
|
|
157
|
+
*/
|
|
158
|
+
function ensureLocalEndpoint(baseURL, opts) {
|
|
159
|
+
if (isLocalEndpoint(baseURL)) return true;
|
|
160
|
+
const err = new Error(remoteRefusal(baseURL, opts));
|
|
161
|
+
err.status = 400;
|
|
162
|
+
err.code = 'CUSTOM_ENDPOINT_NOT_LOCAL';
|
|
163
|
+
throw err;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The settings-store rows whose base URL is dialled DIRECTLY by the transports
|
|
168
|
+
* in providers.js — i.e. the rows the gate above exists for.
|
|
169
|
+
*
|
|
170
|
+
* 'openai-compat', 'anthropic' the desktop's two custom endpoints (its
|
|
171
|
+
* Settings pane names them exactly so, and
|
|
172
|
+
* engine.js's CUSTOM_CLASSES reads the row
|
|
173
|
+
* under the class name).
|
|
174
|
+
* 'custom:*' the CLI's per-model namespace (src/
|
|
175
|
+
* custommodels.js). The CLI keeps the base URL
|
|
176
|
+
* in config.json `customModels` and stores only
|
|
177
|
+
* the KEY here, so this prefix is a second lock
|
|
178
|
+
* on a door that is already shut — deliberate:
|
|
179
|
+
* a future writer that puts a URL in this row
|
|
180
|
+
* must not silently reopen the lane.
|
|
181
|
+
*
|
|
182
|
+
* It matters that this is a NAMED list and not "every row". The same store
|
|
183
|
+
* holds the billed lanes' rows, and those legitimately carry remote values or
|
|
184
|
+
* nothing at all:
|
|
185
|
+
* · `byok:<provider>` — always remote by definition (the provider's own API,
|
|
186
|
+
* reached through AEGIS's relay, which is what bills it). The desktop's
|
|
187
|
+
* Settings pane saves those rows with baseURL '' (the relay owns the URL),
|
|
188
|
+
* and a blanket "must be local" rule would refuse even the empty string,
|
|
189
|
+
* breaking BYOK configuration entirely.
|
|
190
|
+
* · `aegis` — reserved namespace, never touched through this surface.
|
|
191
|
+
* So a gate keyed on the value alone is wrong; it is keyed on the ROW.
|
|
192
|
+
*/
|
|
193
|
+
const DIRECT_DIAL_ROWS = Object.freeze(['openai-compat', 'anthropic']);
|
|
194
|
+
const DIRECT_DIAL_PREFIXES = Object.freeze(['custom:']);
|
|
195
|
+
|
|
196
|
+
/** Whether a settings-store row's base URL is dialled directly (and so gated). */
|
|
197
|
+
function isDirectDialRow(provider) {
|
|
198
|
+
const p = String(provider == null ? '' : provider);
|
|
199
|
+
if (DIRECT_DIAL_ROWS.includes(p)) return true;
|
|
200
|
+
return DIRECT_DIAL_PREFIXES.some((pre) => p.startsWith(pre));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Whether a row may STORE this base URL — the storage seam's question, which is
|
|
205
|
+
* not quite the dispatch seam's.
|
|
206
|
+
*
|
|
207
|
+
* A direct-dial row with no URL is fine to store: that is how a row is cleared,
|
|
208
|
+
* and how it looks before it is configured (`customStatus` reports it as
|
|
209
|
+
* unconfigured, so nothing dials it). What must never be stored is a NON-EMPTY
|
|
210
|
+
* remote URL, which would make the row look configured and usable.
|
|
211
|
+
*/
|
|
212
|
+
function allowsDirectDialRow(provider, baseURL) {
|
|
213
|
+
if (!isDirectDialRow(provider)) return true;
|
|
214
|
+
const url = String(baseURL == null ? '' : baseURL).trim();
|
|
215
|
+
if (!url) return true; // clearing the row, or a not-yet-configured one
|
|
216
|
+
return isLocalEndpoint(url);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = {
|
|
220
|
+
hostOf,
|
|
221
|
+
isLocalEndpoint,
|
|
222
|
+
remoteRefusal,
|
|
223
|
+
ensureLocalEndpoint,
|
|
224
|
+
isDirectDialRow,
|
|
225
|
+
allowsDirectDialRow,
|
|
226
|
+
ALLOWED_HINT,
|
|
227
|
+
DIRECT_DIAL_ROWS,
|
|
228
|
+
};
|
package/lib/local/engine.js
CHANGED
|
@@ -45,6 +45,10 @@ const os = require('node:os');
|
|
|
45
45
|
|
|
46
46
|
const toolsModule = require('./tools.js');
|
|
47
47
|
const promptModule = require('./prompt.js');
|
|
48
|
+
// The direct-dial policy: which base URLs may reach a provider transport, and
|
|
49
|
+
// why a remote one is refused rather than metered. The same module the CLI
|
|
50
|
+
// resolves (cli/src/custommodels.js), so both hosts answer with one rule.
|
|
51
|
+
const { isLocalEndpoint, remoteRefusal } = require('./endpoints.js');
|
|
48
52
|
const { ShellSession } = require('./shell.js');
|
|
49
53
|
const { agentSystemPrompt, agentRoleLabel } = require('./agents.js');
|
|
50
54
|
// Cooperative working-tree sharing (see each module's header). The lock
|
|
@@ -657,14 +661,26 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
657
661
|
* a base URL is mandatory for both, and Anthropic additionally needs its own
|
|
658
662
|
* key (the wire format authenticates with x-api-key). Reporting them as
|
|
659
663
|
* always-ready made chat() POST to `${undefined}/v1/…` (defect #2).
|
|
664
|
+
*
|
|
665
|
+
* A stored REMOTE base URL is reported as not-configured rather than ready,
|
|
666
|
+
* plus `blocked` and the reason. This is the third face of the direct-dial
|
|
667
|
+
* gate (storage in settings.js set, dispatch in chat below): a row that
|
|
668
|
+
* predates the rule must not be OFFERED either, or the class list advertises
|
|
669
|
+
* a lane the turn then refuses. The reason travels so the UI can explain it
|
|
670
|
+
* instead of showing a dead row — and it is recoverable by design: saving a
|
|
671
|
+
* local URL (or clearing the field) makes the class usable again, which is
|
|
672
|
+
* what the refusal text tells the user to do.
|
|
660
673
|
*/
|
|
661
674
|
function customStatus(cls) {
|
|
662
675
|
const cfg = settings.get(cls) || {};
|
|
663
676
|
const baseURL = typeof cfg.baseURL === 'string' ? cfg.baseURL.trim() : '';
|
|
664
677
|
const hasBase = Boolean(baseURL);
|
|
665
678
|
const hasKey = Boolean(cfg.configured);
|
|
679
|
+
const blocked = hasBase && !isLocalEndpoint(baseURL);
|
|
666
680
|
return {
|
|
667
|
-
configured: cls === 'anthropic' ? hasBase && hasKey : hasBase,
|
|
681
|
+
configured: !blocked && (cls === 'anthropic' ? hasBase && hasKey : hasBase),
|
|
682
|
+
blocked,
|
|
683
|
+
...(blocked ? { blockedReason: remoteRefusal(baseURL, { subject: `the ${cls} endpoint` }) } : {}),
|
|
668
684
|
baseURL,
|
|
669
685
|
keyMask: cfg.keyMask || null,
|
|
670
686
|
};
|
|
@@ -714,14 +730,27 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
714
730
|
if (cls === 'byok') {
|
|
715
731
|
// The server's catalog names every provider it accepts a key for, the
|
|
716
732
|
// models each unlocks, and whether an AEGIS account key is even needed
|
|
717
|
-
// to ask (it is not —
|
|
718
|
-
// NOT gated on aegis.apiKey the way the pooled class above
|
|
719
|
-
// whole point is a caller who brings their own credential, and
|
|
720
|
-
// catalog
|
|
733
|
+
// to ask (it is not — the catalog answers an anonymous request).
|
|
734
|
+
// Deliberately NOT gated on aegis.apiKey the way the pooled class above
|
|
735
|
+
// is: BYOK's whole point is a caller who brings their own credential, and
|
|
736
|
+
// hiding the catalog would hide the answer to "which key do I go and get"
|
|
737
|
+
// from exactly the person deciding whether to bother. Nothing here
|
|
738
|
+
// enforces billing either: the desktop is one client of a route that is
|
|
739
|
+
// unauthenticated by design, so refusing in this process would stop
|
|
740
|
+
// exactly one host out of many. The relay owns that decision, and reports
|
|
741
|
+
// it via `fee.require_balance` below.
|
|
721
742
|
let providers = [];
|
|
743
|
+
let fee = null;
|
|
722
744
|
try {
|
|
723
745
|
const data = await aegis.byokProviders();
|
|
724
746
|
providers = (data && data.providers) || [];
|
|
747
|
+
// The handling fee AEGIS adds on top of the caller's vendor bill. It is
|
|
748
|
+
// the server's own published rate (services/pricing.price_byok_call) and
|
|
749
|
+
// is passed through untouched — never re-derived here, because a client
|
|
750
|
+
// that hardcodes a fee is a client that can disagree with the ledger.
|
|
751
|
+
// Absent until the server publishes one, and the UI must then say
|
|
752
|
+
// nothing rather than show a guess.
|
|
753
|
+
fee = (data && data.fee) || null;
|
|
725
754
|
} catch {
|
|
726
755
|
providers = [];
|
|
727
756
|
}
|
|
@@ -744,6 +773,8 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
744
773
|
return {
|
|
745
774
|
class: cls, models, providers,
|
|
746
775
|
needsProviderKey: models.length > 0 && !models.some((m) => m.configured),
|
|
776
|
+
needsAegisKey: !aegis.apiKey,
|
|
777
|
+
fee,
|
|
747
778
|
};
|
|
748
779
|
}
|
|
749
780
|
// Custom endpoints: the model id is the *user's* choice — a provider model
|
|
@@ -755,7 +786,18 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
755
786
|
// along for display only.
|
|
756
787
|
const cfg = settings.get(cls) || {};
|
|
757
788
|
const baseURL = typeof cfg.baseURL === 'string' ? cfg.baseURL.trim() : '';
|
|
758
|
-
|
|
789
|
+
// Same reporting as customStatus: a stored remote URL is not a usable
|
|
790
|
+
// model list, and the reason has to reach the UI with it (see customStatus).
|
|
791
|
+
const blocked = Boolean(baseURL) && !isLocalEndpoint(baseURL);
|
|
792
|
+
return {
|
|
793
|
+
class: cls,
|
|
794
|
+
models: [],
|
|
795
|
+
needsModelId: true,
|
|
796
|
+
baseURL,
|
|
797
|
+
...(blocked
|
|
798
|
+
? { blocked: true, blockedReason: remoteRefusal(baseURL, { subject: `the ${cls} endpoint` }) }
|
|
799
|
+
: {}),
|
|
800
|
+
};
|
|
759
801
|
}
|
|
760
802
|
|
|
761
803
|
/**
|
|
@@ -941,18 +983,42 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
941
983
|
// automatically by byokChatCompletion() as X-AEGIS-Key so the account
|
|
942
984
|
// gets billed the handling fee; see client/aegis.js.
|
|
943
985
|
const { provider, model: bareModel } = splitByokModel(opts.model);
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
986
|
+
try {
|
|
987
|
+
return await aegis.byokChatCompletion({
|
|
988
|
+
provider,
|
|
989
|
+
model: bareModel,
|
|
990
|
+
providerKey: opts.apiKey,
|
|
991
|
+
prompt: opts.prompt,
|
|
992
|
+
system: opts.system,
|
|
993
|
+
messages: opts.messages,
|
|
994
|
+
maxTokens: opts.maxTokens,
|
|
995
|
+
stream: true,
|
|
996
|
+
onStream: opts.onDelta,
|
|
997
|
+
signal: opts.signal,
|
|
998
|
+
});
|
|
999
|
+
} catch (e) {
|
|
1000
|
+
// The relay's own balance gate (aegis1 `AEGIS_BYOK_REQUIRE_BALANCE`,
|
|
1001
|
+
// app.py `byok_chat_completions`) answers 402 with a body aimed at an
|
|
1002
|
+
// API consumer — "Insufficient balance. Top up to continue using
|
|
1003
|
+
// BYOK." Shown raw in a chat transcript that reads as a crash rather
|
|
1004
|
+
// than a bill, so the one thing the user has to DO is said here, in the
|
|
1005
|
+
// hosts' own voice. The status is preserved so every existing caller
|
|
1006
|
+
// (the CLI's error painter, the desktop turn guard) still sees a 402.
|
|
1007
|
+
//
|
|
1008
|
+
// The provider key is untouched by this: it is the caller's own and it
|
|
1009
|
+
// is still valid. What ran out is the AEGIS balance the handling fee
|
|
1010
|
+
// is billed against, which is the whole reason this lane has a fee.
|
|
1011
|
+
if (e && e.status === 402) {
|
|
1012
|
+
const err = new Error(
|
|
1013
|
+
'byok: this account has no AEGIS balance left, and the BYOK handling fee is ' +
|
|
1014
|
+
'billed there — your provider key is still valid, this is not a key problem. ' +
|
|
1015
|
+
'Top up the account, then try again (or /class aegis to use the pooled lane).'
|
|
1016
|
+
);
|
|
1017
|
+
err.status = 402;
|
|
1018
|
+
throw err;
|
|
1019
|
+
}
|
|
1020
|
+
throw e;
|
|
1021
|
+
}
|
|
956
1022
|
}
|
|
957
1023
|
|
|
958
1024
|
const common = {
|
|
@@ -1093,6 +1159,60 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
1093
1159
|
err.status = 400;
|
|
1094
1160
|
throw err;
|
|
1095
1161
|
}
|
|
1162
|
+
// …and the AEGIS account key is what makes the turn BILLABLE at all. The
|
|
1163
|
+
// relay authenticates on the provider key and resolves the payer
|
|
1164
|
+
// separately, from X-AEGIS-Key (app.py `_byok_identify_user`): with no
|
|
1165
|
+
// account key the server can attribute the handling fee to no one, so it
|
|
1166
|
+
// is logged against user 0 as uncollected — an anonymous free ride the
|
|
1167
|
+
// fee exists to close. Require the account key here so EVERY BYOK turn is
|
|
1168
|
+
// attributed and every caller pays the handling fee: a funded balance is
|
|
1169
|
+
// debited immediately, an unfunded one records the fee as owed
|
|
1170
|
+
// (token_bank.charge_byok clamps to zero and never refuses), and nobody
|
|
1171
|
+
// is served free. Balance is the server's own concern, not this gate's —
|
|
1172
|
+
// with a balance or without one, the account still pays. `listModels`
|
|
1173
|
+
// still answers anonymously (the catalog is how a user finds out which
|
|
1174
|
+
// key to get), so this refuses only the send, and only until a key lands.
|
|
1175
|
+
if (cls === 'byok' && !aegis.apiKey) {
|
|
1176
|
+
const err = new Error(
|
|
1177
|
+
'byok: connect your AEGIS account key first — the BYOK handling fee is billed there. ' +
|
|
1178
|
+
'Add it in the Status card (or run /login), then try again.'
|
|
1179
|
+
);
|
|
1180
|
+
err.status = 401;
|
|
1181
|
+
throw err;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// THE DIRECT-DIAL GATE. A custom class talks to the user's base URL
|
|
1185
|
+
// itself (providers.js openaiCompatible / anthropicMessages), and that
|
|
1186
|
+
// transport bills NOBODY: no pooled margin, no BYOK handling fee, no
|
|
1187
|
+
// account key attached. The only usage it may therefore carry is an
|
|
1188
|
+
// endpoint on this machine, where there is no vendor to pay. A remote URL
|
|
1189
|
+
// here is an unpaid turn, and it is refused rather than metered because
|
|
1190
|
+
// there is nothing to meter it against — the relay accepts a fixed
|
|
1191
|
+
// catalog of provider ids (services/nexus_provider/catalog.py), so an
|
|
1192
|
+
// arbitrary remote URL cannot be billed there either.
|
|
1193
|
+
//
|
|
1194
|
+
// Checked at DISPATCH and not only at storage (settings.js set refuses
|
|
1195
|
+
// the same URL): a row written before this rule existed, or hand-edited
|
|
1196
|
+
// into settings.json, is still in the file, and reading it back happily
|
|
1197
|
+
// would keep the lane open. Both seams, one rule — the message is
|
|
1198
|
+
// endpoints.js's, so the desktop and the CLI say the same thing.
|
|
1199
|
+
if (CUSTOM_CLASSES.includes(cls)) {
|
|
1200
|
+
const raw = cfg && typeof cfg.baseURL === 'string' ? cfg.baseURL.trim() : '';
|
|
1201
|
+
if (raw && !isLocalEndpoint(raw)) {
|
|
1202
|
+
const err = new Error(
|
|
1203
|
+
remoteRefusal(raw, {
|
|
1204
|
+
subject: `the ${cls} endpoint`,
|
|
1205
|
+
hint:
|
|
1206
|
+
'This class dials your URL directly and bills nobody, so it is local-only. ' +
|
|
1207
|
+
'Use a model on this machine, or move the provider to the BYOK card (/class byok), ' +
|
|
1208
|
+
'which relays through AEGIS and charges the handling fee.',
|
|
1209
|
+
})
|
|
1210
|
+
);
|
|
1211
|
+
err.status = 400;
|
|
1212
|
+
err.code = 'CUSTOM_ENDPOINT_NOT_LOCAL';
|
|
1213
|
+
throw err;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1096
1216
|
|
|
1097
1217
|
// Custom classes carry no enumerable model list (see listModels), so a
|
|
1098
1218
|
// blank id here means the user never typed one. Fail loudly in-process
|
package/lib/settings.js
CHANGED
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
|
|
18
18
|
const fs = require('node:fs');
|
|
19
19
|
const path = require('node:path');
|
|
20
|
+
// The direct-dial policy (see that file): which rows may hold a remote base URL.
|
|
21
|
+
// Enforced HERE, at the storage seam, so the unusable configuration cannot be
|
|
22
|
+
// created by either host — the desktop's Settings pane or the CLI's `/model add`
|
|
23
|
+
// — rather than only being refused at send time. Pure node builtins, so this
|
|
24
|
+
// store stays loadable without Electron.
|
|
25
|
+
const { isLocalEndpoint, remoteRefusal, isDirectDialRow } = require('./local/endpoints.js');
|
|
20
26
|
|
|
21
27
|
const SETTINGS_FILE = 'settings.json';
|
|
22
28
|
|
|
@@ -124,8 +130,13 @@ function createSettingsStore({ dir, safeStorage } = {}) {
|
|
|
124
130
|
function save(data) {
|
|
125
131
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
126
132
|
const tmp = `${file}.tmp-${process.pid}`;
|
|
127
|
-
|
|
133
|
+
// 0600, written before the rename so the file is never briefly readable:
|
|
134
|
+
// this store holds provider keys, and both hosts are now able to write it
|
|
135
|
+
// (the CLI uses the same file, without Electron's safeStorage, so at-rest
|
|
136
|
+
// base64 is all the protection there is — file mode is the real control).
|
|
137
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
128
138
|
fs.renameSync(tmp, file);
|
|
139
|
+
try { fs.chmodSync(file, 0o600); } catch {}
|
|
129
140
|
}
|
|
130
141
|
|
|
131
142
|
/** Provider CRUD must never touch a reserved (AEGIS-key) namespace. */
|
|
@@ -149,8 +160,38 @@ function createSettingsStore({ dir, safeStorage } = {}) {
|
|
|
149
160
|
};
|
|
150
161
|
}
|
|
151
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Write a provider row.
|
|
165
|
+
*
|
|
166
|
+
* A direct-dial row ('openai-compat', 'anthropic', 'custom:*') may not hold a
|
|
167
|
+
* remote base URL: those classes talk to the URL themselves (providers.js),
|
|
168
|
+
* which bills nobody, so the only usage they may carry is an endpoint on this
|
|
169
|
+
* machine. Refusing at STORAGE — the earliest seam, shared by the desktop's
|
|
170
|
+
* Settings pane and the CLI — means a remote custom endpoint cannot be
|
|
171
|
+
* configured into existence in the first place; the engine's dispatch gate
|
|
172
|
+
* then covers the rows that predate this rule or were hand-edited into the
|
|
173
|
+
* file. Empty is allowed (that is how the row is cleared, and how a
|
|
174
|
+
* not-yet-configured row looks), and every other namespace is untouched:
|
|
175
|
+
* `byok:<provider>` rows are remote by definition and reach their provider
|
|
176
|
+
* through AEGIS's relay, which is what bills them.
|
|
177
|
+
*/
|
|
152
178
|
function set(provider, { baseURL, key } = {}) {
|
|
153
179
|
assertNotReserved(provider);
|
|
180
|
+
if (baseURL !== undefined && isDirectDialRow(provider)) {
|
|
181
|
+
const next = String(baseURL == null ? '' : baseURL).trim();
|
|
182
|
+
if (next && !isLocalEndpoint(next)) {
|
|
183
|
+
const err = new Error(remoteRefusal(next, {
|
|
184
|
+
subject: `the ${provider} endpoint`,
|
|
185
|
+
hint:
|
|
186
|
+
'This lane is for a model running on this machine. To use a remote provider, ' +
|
|
187
|
+
'save it under Bring-your-own-key (the CLI\'s /class byok), where the AEGIS handling ' +
|
|
188
|
+
'fee bills the turn.',
|
|
189
|
+
}));
|
|
190
|
+
err.status = 400;
|
|
191
|
+
err.code = 'CUSTOM_ENDPOINT_NOT_LOCAL';
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
154
195
|
const data = load();
|
|
155
196
|
const cfg = data[provider] || {};
|
|
156
197
|
if (baseURL !== undefined) cfg.baseURL = baseURL;
|