aegis-desktop 1.0.10 → 1.0.13

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.
@@ -0,0 +1,136 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
3
+ // AEGIS Code (aegiscode) — source: https://github.com/aegisinfo/aegiscode-plugin
4
+ // Conveying a modified copy, or running one as a network service, owes the
5
+ // corresponding source under this licence (§5/§13) — see LICENSING.md.
6
+ 'use strict';
7
+
8
+ /**
9
+ * provider-toolbar.js — the Providers & models card: one list, every provider
10
+ * and every model, each with the URL its turn is sent to.
11
+ *
12
+ * Paints data from providers.js and nothing else; it never fetches, so the
13
+ * list cannot disagree with the engine's own catalog. Two rules the markup
14
+ * obeys:
15
+ *
16
+ * - A row is a <button> (not a div with a click handler) when it is
17
+ * selectable, so it is reachable by keyboard and announces itself. A
18
+ * non-selectable row (a class's state: "no key", "no daemon") is a plain
19
+ * <li> — making it a button would promise a click that does nothing.
20
+ * - The URL line is text, never a link: these are addresses the app sends to,
21
+ * not things a user should open. A chrome-less window opening its own IPC
22
+ * base in a browser is noise at best.
23
+ */
24
+
25
+ (function () {
26
+ if (typeof window === 'undefined') return;
27
+ const A = window.aegisProviders;
28
+ if (!A) return; // providers.js missing: leave the card on its placeholder
29
+
30
+ function el(tag, cls, text) {
31
+ const n = document.createElement(tag);
32
+ if (cls) n.className = cls;
33
+ if (text != null) n.textContent = text;
34
+ return n;
35
+ }
36
+
37
+ /**
38
+ * @param {object} opts
39
+ * @param {HTMLElement} opts.list container for the rows
40
+ * @param {HTMLElement} opts.count "N of M" line
41
+ * @param {HTMLInputElement} opts.search
42
+ * @param {HTMLElement} opts.hint
43
+ * @param {(sel:{class:string,model:string})=>void} opts.onSelect
44
+ */
45
+ function mount(opts) {
46
+ const o = opts || {};
47
+ let entries = [];
48
+
49
+ function paintRow(e) {
50
+ const li = el('li', 'pt-row');
51
+ if (e.selectable) {
52
+ const btn = el('button', 'pt-pick');
53
+ btn.type = 'button';
54
+ btn.appendChild(el('span', 'pt-model', e.modelLabel || e.model));
55
+ if (e.model && e.modelLabel && e.modelLabel !== e.model) {
56
+ btn.appendChild(el('span', 'pt-id', e.model));
57
+ }
58
+ btn.appendChild(el('span', 'pt-target', e.url ? `${e.method} ${e.url}` : 'no network route — runs as a local process'));
59
+ btn.addEventListener('click', () => {
60
+ if (o.onSelect) o.onSelect(A.selectionFor(e));
61
+ });
62
+ li.appendChild(btn);
63
+ } else {
64
+ li.appendChild(el('span', 'pt-model pt-muted', e.modelLabel));
65
+ li.appendChild(el('span', 'pt-target', e.url ? `${e.method} ${e.url}` : '—'));
66
+ }
67
+ if (e.billing) li.appendChild(el('span', 'pt-billing', e.billing));
68
+ if (e.blocked) li.appendChild(el('span', 'pt-blocked', e.blocked));
69
+ return li;
70
+ }
71
+
72
+ /** Group rows under their class heading, preserving class order. */
73
+ function paint(query) {
74
+ const shown = A.filterEntries(entries, query);
75
+ if (o.count) {
76
+ o.count.textContent = entries.length
77
+ ? `${shown.length} of ${entries.length} provider route${entries.length === 1 ? '' : 's'}`
78
+ : 'no providers reported';
79
+ }
80
+ if (o.list) {
81
+ o.list.innerHTML = '';
82
+ let group = null;
83
+ let ul = null;
84
+ for (const e of shown) {
85
+ if (e.group !== group) {
86
+ group = e.group;
87
+ const section = el('li', 'pt-group');
88
+ section.appendChild(el('h3', 'pt-group-title', group));
89
+ ul = el('ul', 'pt-rows');
90
+ section.appendChild(ul);
91
+ o.list.appendChild(section);
92
+ }
93
+ ul.appendChild(paintRow(e));
94
+ }
95
+ if (!shown.length) {
96
+ o.list.appendChild(el('li', 'pt-empty', entries.length
97
+ ? 'No provider or model matches that filter.'
98
+ : 'No provider paths yet — press Refresh once a key is saved.'));
99
+ }
100
+ }
101
+ if (o.hint && query && shown.length) {
102
+ // Say where each hit goes, in one line, so the tallest question the
103
+ // list answers ("does this leave my machine?") is answerable at a
104
+ // glance rather than row by row.
105
+ const remote = shown.filter((e) => e.url && !/localhost|127\.0\.0\.1/.test(e.url)).length;
106
+ const local = shown.filter((e) => e.url && /localhost|127\.0\.0\.1/.test(e.url)).length;
107
+ const off = shown.filter((e) => !e.url).length;
108
+ o.hint.textContent = [
109
+ remote ? `${remote} over the network` : '',
110
+ local ? `${local} on this machine` : '',
111
+ off ? `${off} with no route` : '',
112
+ ].filter(Boolean).join(' · ');
113
+ } else if (o.hint && !query) {
114
+ o.hint.textContent = entries.length
115
+ ? 'Every provider this build can address, and the URL a turn to it uses.'
116
+ : '';
117
+ }
118
+ }
119
+
120
+ if (o.search) {
121
+ o.search.addEventListener('input', () => paint(o.search.value));
122
+ }
123
+
124
+ return {
125
+ /** Replace the list. `rows` is the same shape buildProviderPaths takes. */
126
+ set(rows) {
127
+ entries = A.buildProviderPaths(rows);
128
+ paint(o.search ? o.search.value : '');
129
+ return entries;
130
+ },
131
+ entries: () => entries.slice(),
132
+ };
133
+ }
134
+
135
+ window.aegisProviderToolbar = { mount };
136
+ })();
@@ -0,0 +1,261 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
3
+ // AEGIS Code (aegiscode) — source: https://github.com/aegisinfo/aegiscode-plugin
4
+ // Conveying a modified copy, or running one as a network service, owes the
5
+ // corresponding source under this licence (§5/§13) — see LICENSING.md.
6
+ 'use strict';
7
+
8
+ /**
9
+ * providers.js — one flat list of every provider and every model this build can
10
+ * address, each with the URL a turn to it actually goes to.
11
+ *
12
+ * WHY THIS EXISTS. The Model card answers "what is selected"; it never answers
13
+ * "what can I select, and where does that go". Those are different questions,
14
+ * and the second one had no surface at all: a user could see four provider
15
+ * classes and one model dropdown, and still have no way to learn that
16
+ * `byok:anthropic:claude-sonnet-5` is sent to the relay, that `local:llama3.2`
17
+ * goes to localhost, or that `pro` is not a URL at all but a child process.
18
+ *
19
+ * TWO DELIBERATE OMISSIONS, and they are the point of the file's shape:
20
+ *
21
+ * - No φ(α) seal row. The authorship panel is not a provider; it publishes a
22
+ * record of a repo, and putting it in a provider table would invite the
23
+ * reader to think a turn can be addressed to it.
24
+ * - No autonomous-box row. The fan-out is a *property of a turn* on the
25
+ * Aegis Cloud class (engine.js AUTONOMOUS_CLASS), not an endpoint — it
26
+ * reaches no route the pooled row does not already name. It already has its
27
+ * own toggle in the Model card, next to the class that has it at all.
28
+ *
29
+ * Both omissions are asserted in test/renderer-providers.test.mjs so a later
30
+ * "why is this missing" pass cannot quietly re-add them.
31
+ *
32
+ * The URLS ARE READ OFF THE TRANSPORT, not guessed:
33
+ * - pooled : vendor/aegis.js -> '/api/v1/chat/completions', under
34
+ * DEFAULT_API_BASE ('https://aegiscloud.org')
35
+ * - byok : vendor/aegis.js -> '/api/v1/byok/chat/completions', same base,
36
+ * with a per-request X-Provider-Key
37
+ * - local : lib/local/local.js -> `${base}/v1/chat/completions`, base
38
+ * DEFAULT_BASE ('http://localhost:11434')
39
+ * - pro : no route — a `claude` child process (claude-pro.js PRO_MODELS
40
+ * are CLI aliases, not wire ids), so `url` is null and the row
41
+ * says so instead of printing a plausible-looking URL.
42
+ *
43
+ * Pure: no DOM, no Electron, no fetch. Takes the engine's own listClasses /
44
+ * listModels payloads and returns data; the toolbar paints it.
45
+ */
46
+
47
+ /** Paths, as the transport spells them. Not templates — literal strings. */
48
+ const POOLED_PATH = '/api/v1/chat/completions';
49
+ const BYOK_PATH = '/api/v1/byok/chat/completions';
50
+ const LOCAL_PATH = '/v1/chat/completions';
51
+ const DEFAULT_CLOUD_BASE = 'https://aegiscloud.org';
52
+ const DEFAULT_LOCAL_BASE = 'http://localhost:11434';
53
+
54
+ /** Join a base and a path without doubling or dropping the separator. */
55
+ function joinUrl(base, suffix) {
56
+ const b = typeof base === 'string' ? base.trim() : '';
57
+ const p = typeof suffix === 'string' ? suffix.trim() : '';
58
+ if (!b) return '';
59
+ // An absolute URL in the path slot wins: a deployment may publish a full
60
+ // base, and prefixing that with another base produces a URL that resolves to
61
+ // nothing while looking correct on screen.
62
+ if (/^https?:\/\//i.test(p)) return p;
63
+ if (!p) return b.replace(/\/+$/, '');
64
+ return `${b.replace(/\/+$/, '')}/${p.replace(/^\/+/, '')}`;
65
+ }
66
+
67
+ /**
68
+ * The billing line for a row. Read from what the engine already reported rather
69
+ * than assumed: a byok row's fee note only exists when the server publishes one
70
+ * (`fee.require_balance`), and saying "funded balance required" on a
71
+ * deployment that does not gate that way is the same lie pointing the other
72
+ * way (see engine.listModels' byok branch).
73
+ */
74
+ function billingFor(cls, classRow, modelRow, listPayload) {
75
+ if (cls === 'pro') {
76
+ return 'Claude Code subscription — no AEGIS tokens spent';
77
+ }
78
+ if (cls === 'local') {
79
+ return 'free — runs on this machine';
80
+ }
81
+ if (cls === 'byok') {
82
+ const fee = (listPayload && listPayload.fee) || null;
83
+ if (fee && fee.require_balance) {
84
+ return 'your provider key pays the vendor; an AEGIS key and a funded balance pay the handling fee';
85
+ }
86
+ return 'your provider key pays the vendor; an AEGIS key attributes the handling fee';
87
+ }
88
+ // aegis (pooled): the account pays. No claim is made about which plan covers
89
+ // it — `configured` already says whether a key is present, and the Status
90
+ // card is where plan and balance actually live.
91
+ return 'billed to your AEGIS account';
92
+ }
93
+
94
+ /**
95
+ * Why a row cannot be used, or null when it can. Deliberately one short reason
96
+ * per row: the Model card's hint already carries the full remedy for the
97
+ * selected class, and duplicating that copy per row makes the list unreadable.
98
+ */
99
+ function blockedReason(cls, classRow, modelRow, listPayload) {
100
+ if (cls === 'aegis' && listPayload && listPayload.needsKey) {
101
+ return 'no AEGIS API key on this machine';
102
+ }
103
+ if (cls === 'local' && listPayload && listPayload.needsDaemon) {
104
+ return 'no model server listening';
105
+ }
106
+ if (cls === 'local' && listPayload && listPayload.listed === false) {
107
+ return 'server answered but lists no models';
108
+ }
109
+ if (cls === 'byok' && modelRow && modelRow.configured === false) {
110
+ // The precise reason when the env file REFUSED a value it was given, rather
111
+ // than never having been given one — that distinction is the difference
112
+ // between "add a key" and "the key you added was rejected".
113
+ return modelRow.keyRejected
114
+ ? `provider key rejected: ${modelRow.keyRejected}`
115
+ : 'no key saved for this provider';
116
+ }
117
+ if (cls === 'pro' && classRow && classRow.configured === false) {
118
+ return 'not signed in to Claude Code';
119
+ }
120
+ return null;
121
+ }
122
+
123
+ /**
124
+ * One row per addressable model, in class order, plus a placeholder row for a
125
+ * class that offers no model list but is still a target (a pooled turn with no
126
+ * model pinned, a local daemon with nothing pulled yet).
127
+ *
128
+ * `rows` is `[{ class, classLabel, classConfigured, list }]`, where `list` is
129
+ * whatever `models.listModels(cls)` resolved to — including its error/state
130
+ * shape, so a class that failed to list still gets a row that says why.
131
+ */
132
+ function buildProviderPaths(rows) {
133
+ const out = [];
134
+ const input = Array.isArray(rows) ? rows : [];
135
+ for (const r of input) {
136
+ if (!r || typeof r.class !== 'string') continue;
137
+ const cls = r.class;
138
+ const classRow = { configured: r.classConfigured !== false };
139
+ const list = r.list && typeof r.list === 'object' ? r.list : null;
140
+ const models = Array.isArray(list && list.models) ? list.models : [];
141
+ const cloudBase = (list && (list.apiBase || list.baseURL)) || DEFAULT_CLOUD_BASE;
142
+ const localBase = (list && list.baseURL) || DEFAULT_LOCAL_BASE;
143
+
144
+ if (!models.length) {
145
+ // No model row still means a real destination for two classes: the pooled
146
+ // class runs the server's default when nothing is pinned, and a local
147
+ // daemon with nothing pulled still answers on the same route once a tag
148
+ // is typed (engine.listModels' local branch). For byok and pro an empty
149
+ // list is genuinely "nothing to address", so only the reason is shown.
150
+ const placeholderOk = cls === 'aegis' || cls === 'local';
151
+ out.push({
152
+ class: cls,
153
+ classLabel: r.classLabel || cls,
154
+ model: '',
155
+ modelLabel:
156
+ cls === 'aegis'
157
+ ? 'server default (auto)'
158
+ : cls === 'local'
159
+ ? 'type a model tag in the Model card'
160
+ : '—',
161
+ provider: '',
162
+ uniqueModel: cls,
163
+ group: r.classLabel || cls,
164
+ url: placeholderOk
165
+ ? joinUrl(cls === 'local' ? localBase : cloudBase, cls === 'local' ? LOCAL_PATH : POOLED_PATH)
166
+ : null,
167
+ method: placeholderOk ? 'POST' : null,
168
+ billing: billingFor(cls, classRow, null, list),
169
+ blocked: blockedReason(cls, classRow, null, list),
170
+ // A row with no model id is not a selectable target: it is the class's
171
+ // own state, printed where its models would have gone.
172
+ selectable: false,
173
+ });
174
+ continue;
175
+ }
176
+
177
+ for (const m of models) {
178
+ if (!m || !m.id) continue;
179
+ const provider = typeof m.provider === 'string' && m.provider ? m.provider : cls;
180
+ let url = null;
181
+ let method = null;
182
+ if (cls === 'aegis') {
183
+ url = joinUrl(cloudBase, POOLED_PATH);
184
+ method = 'POST';
185
+ } else if (cls === 'byok') {
186
+ // The relay's route, INCLUDING for provider `custom`. That row looks
187
+ // like an exception and is not: this app never dials the user's own
188
+ // endpoint. It posts to the relay with `base_url` in the body and the
189
+ // server makes the outbound call (client/aegis.js byokChatCompletion,
190
+ // aegis1 `_byok_custom_base_url`). Printing the endpoint here would
191
+ // describe a connection no part of this client opens.
192
+ url = joinUrl(cloudBase, BYOK_PATH);
193
+ method = 'POST';
194
+ } else if (cls === 'local') {
195
+ url = joinUrl(localBase, LOCAL_PATH);
196
+ method = 'POST';
197
+ }
198
+ // cls === 'pro' leaves both null: it is a child process, not a route.
199
+ out.push({
200
+ class: cls,
201
+ classLabel: r.classLabel || cls,
202
+ model: m.id,
203
+ modelLabel: m.label || m.id,
204
+ provider,
205
+ uniqueModel: `${cls}:${m.id}`,
206
+ group: r.classLabel || cls,
207
+ url,
208
+ method,
209
+ billing: billingFor(cls, classRow, m, list),
210
+ blocked: blockedReason(cls, classRow, m, list),
211
+ selectable: true,
212
+ });
213
+ }
214
+ }
215
+ return out;
216
+ }
217
+
218
+ /**
219
+ * Case-insensitive substring match across everything a user might type: the
220
+ * model id, its label, the provider, the class, and the URL's host+path.
221
+ * Multi-term: every whitespace-separated term must match somewhere, so
222
+ * "anthropic sonnet" narrows instead of returning nothing.
223
+ */
224
+ function filterEntries(entries, query) {
225
+ const list = Array.isArray(entries) ? entries : [];
226
+ const q = String(query == null ? '' : query).trim().toLowerCase();
227
+ if (!q) return list.slice();
228
+ const terms = q.split(/\s+/).filter(Boolean);
229
+ return list.filter((e) => {
230
+ const hay = [e.model, e.modelLabel, e.provider, e.class, e.classLabel, e.url || '']
231
+ .join('\n')
232
+ .toLowerCase();
233
+ return terms.every((t) => hay.includes(t));
234
+ });
235
+ }
236
+
237
+ /**
238
+ * The selection a row would produce, in the shape the Model card's own controls
239
+ * take: `{ class, model }` for the two <select>s. `model` is '' for a class
240
+ * whose picker offers the "auto" entry, which is exactly what selecting that
241
+ * option means today.
242
+ */
243
+ function selectionFor(entry) {
244
+ if (!entry) return null;
245
+ return {
246
+ class: entry.class,
247
+ model: entry.class === 'aegis' && !entry.model ? '' : entry.model,
248
+ };
249
+ }
250
+
251
+ if (typeof module !== 'undefined' && module.exports) {
252
+ module.exports = {
253
+ buildProviderPaths,
254
+ filterEntries,
255
+ selectionFor,
256
+ joinUrl,
257
+ POOLED_PATH,
258
+ BYOK_PATH,
259
+ LOCAL_PATH,
260
+ };
261
+ }