aegis-desktop 0.8.12 → 0.8.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.
- package/lib/auto.js +423 -0
- package/lib/avatar/packs/core-wardrobe.pack.json +1 -1
- package/lib/avatar/packs/local-voice-core.pack.json +1 -1
- package/lib/avatar/packs/seasonal-winter.pack.json +1 -1
- package/lib/avatar/profile.js +10 -1
- package/lib/fingerprint.js +327 -0
- package/lib/local/autonomous.js +1 -0
- package/lib/local/prompt.js +6 -0
- package/lib/settings.js +154 -1
- package/main.js +316 -17
- package/package.json +2 -2
- package/preload.js +84 -4
- package/renderer/app.js +208 -4
- package/renderer/fingerprint.css +143 -0
- package/renderer/fingerprint.js +583 -0
- package/renderer/index.html +163 -1
- package/vendor/aegis.js +180 -18
- package/vendor/attest.js +439 -0
- package/vendor/authorship-key.js +366 -0
- package/vendor/device-fingerprint.js +85 -3
- package/vendor/fingerprint.js +1061 -0
- package/vendor/presence-constants.js +104 -0
- package/vendor/presence.js +435 -0
- package/vendor/record-vault.js +834 -0
- package/vendor/touch.js +455 -0
package/renderer/index.html
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
<title>AEGIS Desktop</title>
|
|
14
14
|
<link rel="stylesheet" href="fonts/fonts.css" />
|
|
15
15
|
<link rel="stylesheet" href="style.css" />
|
|
16
|
+
<link rel="stylesheet" href="fingerprint.css" />
|
|
16
17
|
</head>
|
|
17
18
|
<body>
|
|
18
19
|
<header class="topbar">
|
|
@@ -317,10 +318,68 @@
|
|
|
317
318
|
<span id="queue-fanout-label">fan out this task</span>
|
|
318
319
|
</label>
|
|
319
320
|
<p class="hint" id="queue-boundary"></p>
|
|
321
|
+
<!-- The auto-drain switch. NOT a hidden timer: it is the persisted
|
|
322
|
+
settings.js `__autoDrain` namespace, off in a default install, read
|
|
323
|
+
by desktop/lib/auto.js on EVERY tick — so unchecking it stops the
|
|
324
|
+
very next one. A drain spends a real model on a real checkout, so
|
|
325
|
+
the switch that authorises one unattended is visible here and stays
|
|
326
|
+
visible while it is armed (the label carries the state). The
|
|
327
|
+
interval is the timer's cadence, clamped in settings.js; the select
|
|
328
|
+
is disabled while the switch is off so a pick can never read as an
|
|
329
|
+
armed timer. -->
|
|
330
|
+
<label
|
|
331
|
+
class="flow-toggle"
|
|
332
|
+
for="queue-auto"
|
|
333
|
+
title="Drain pending tasks on a timer, with no click. Off by default. Each drain runs a full tool loop on a real model in the task's own checkout — the timer only ever drains tasks already in this queue, and main.js refuses the tick if another host (the CLI, a systemd worker) holds the lock."
|
|
334
|
+
>
|
|
335
|
+
<input type="checkbox" id="queue-auto" />
|
|
336
|
+
<span id="queue-auto-label">drain automatically</span>
|
|
337
|
+
</label>
|
|
338
|
+
<div class="api-key-row">
|
|
339
|
+
<select id="queue-auto-interval" title="How often the timer checks the queue. It drains nothing while the queue is empty, and nothing at boot: the first tick is one interval after the switch is armed.">
|
|
340
|
+
<option value="30000">every 30s</option>
|
|
341
|
+
<option value="60000" selected>every minute</option>
|
|
342
|
+
<option value="300000">every 5 minutes</option>
|
|
343
|
+
<option value="900000">every 15 minutes</option>
|
|
344
|
+
</select>
|
|
345
|
+
</div>
|
|
346
|
+
<p class="hint" id="queue-auto-hint"></p>
|
|
320
347
|
<ul class="sessions-list" id="queue-list"></ul>
|
|
321
348
|
<p class="hint" id="queue-hint"></p>
|
|
322
349
|
<div class="queue-upgrade" id="queue-upgrade"></div>
|
|
323
350
|
</section>
|
|
351
|
+
|
|
352
|
+
<section class="card">
|
|
353
|
+
<!-- φ(α) — the authorship key and the sealed session records.
|
|
354
|
+
The heading is the click target for the full panel, exactly like
|
|
355
|
+
the Memory card (a <button> may only hold phrasing content, so the
|
|
356
|
+
button goes inside the h2, never around it).
|
|
357
|
+
|
|
358
|
+
Everything on this card is driven by desktop/renderer/fingerprint.js
|
|
359
|
+
over the IPC surface preload.js:171-183 already exposes; app.js only
|
|
360
|
+
mounts it. Nothing here ever receives the key itself — the state
|
|
361
|
+
line shows the derived 12-hex tag. -->
|
|
362
|
+
<h2>
|
|
363
|
+
<button
|
|
364
|
+
type="button"
|
|
365
|
+
class="card-title-btn"
|
|
366
|
+
id="fp-open"
|
|
367
|
+
title="Open the authorship panel — key, sealed records, and the Drive vault"
|
|
368
|
+
>
|
|
369
|
+
<span>Authorship</span>
|
|
370
|
+
<span class="ctb-expand" aria-hidden="true">⤢</span>
|
|
371
|
+
</button>
|
|
372
|
+
</h2>
|
|
373
|
+
<p class="hint" id="fp-card-status">checking…</p>
|
|
374
|
+
<!-- One-click seal, the same action as the panel's. `-card` suffix
|
|
375
|
+
because ids are document-global: two nodes sharing `fp-seal` would
|
|
376
|
+
leave whichever the browser returned second permanently unbound. -->
|
|
377
|
+
<button type="button" id="fp-seal-card" class="btn-block">Seal this session</button>
|
|
378
|
+
<p class="hint" id="fp-card-hint">
|
|
379
|
+
A sealed record proves which key wrote a session, and can carry a
|
|
380
|
+
behaviour digest of the code it touched.
|
|
381
|
+
</p>
|
|
382
|
+
</section>
|
|
324
383
|
</aside>
|
|
325
384
|
|
|
326
385
|
<section class="chat">
|
|
@@ -385,7 +444,7 @@
|
|
|
385
444
|
|
|
386
445
|
The genuinely-free facts — exec/write/edit against your real
|
|
387
446
|
working tree, repos and keys on this disk, a local model server
|
|
388
|
-
with no account, an
|
|
447
|
+
with no account, an open-source client — are NOT reasons to pay, so they
|
|
389
448
|
moved into the free path copy below instead of standing in the
|
|
390
449
|
offer.
|
|
391
450
|
|
|
@@ -587,6 +646,105 @@
|
|
|
587
646
|
</div>
|
|
588
647
|
</div>
|
|
589
648
|
|
|
649
|
+
<!-- φ(α) panel. Same sheet as the memory inspector (the classes are shared on
|
|
650
|
+
purpose: one translucent layer, one Escape policy). Every control is
|
|
651
|
+
bound in fingerprint.js, which is also where these ids are declared — a
|
|
652
|
+
typo in either place fails test/renderer-fingerprint.test.mjs. Nothing
|
|
653
|
+
rendered here is ever the key itself. -->
|
|
654
|
+
<div class="memory-overlay fp-overlay" id="fp-overlay" hidden>
|
|
655
|
+
<div class="memory-backdrop" id="fp-backdrop"></div>
|
|
656
|
+
<div
|
|
657
|
+
class="memory-panel"
|
|
658
|
+
role="dialog"
|
|
659
|
+
aria-modal="true"
|
|
660
|
+
aria-labelledby="fp-panel-title"
|
|
661
|
+
>
|
|
662
|
+
<header class="memory-panel-head">
|
|
663
|
+
<h2 id="fp-panel-title">Authorship φ(α)</h2>
|
|
664
|
+
<span class="memory-count" id="fp-key-tag">none</span>
|
|
665
|
+
<div class="spacer"></div>
|
|
666
|
+
<button type="button" id="fp-close" class="ghost-btn" title="Close (Esc)">
|
|
667
|
+
Close
|
|
668
|
+
</button>
|
|
669
|
+
</header>
|
|
670
|
+
|
|
671
|
+
<div class="fp-rows">
|
|
672
|
+
<div class="fp-row"><span class="fp-k">source</span><span class="fp-v" id="fp-key-source">–</span></div>
|
|
673
|
+
<div class="fp-row"><span class="fp-k">key file</span><span class="fp-v" id="fp-key-path">–</span></div>
|
|
674
|
+
<div class="fp-row"><span class="fp-k">publishes to</span><span class="fp-v" id="fp-vault-target">–</span></div>
|
|
675
|
+
</div>
|
|
676
|
+
<p class="fp-warn" id="fp-key-warning" hidden></p>
|
|
677
|
+
|
|
678
|
+
<div class="fp-controls">
|
|
679
|
+
<div class="fp-line">
|
|
680
|
+
<input
|
|
681
|
+
type="text"
|
|
682
|
+
id="fp-label"
|
|
683
|
+
placeholder="label for the record (optional)"
|
|
684
|
+
autocomplete="off"
|
|
685
|
+
/>
|
|
686
|
+
<button type="button" id="fp-keygen" class="ghost-btn">Mint key</button>
|
|
687
|
+
</div>
|
|
688
|
+
<label
|
|
689
|
+
class="flow-toggle"
|
|
690
|
+
for="fp-publish-on-mint"
|
|
691
|
+
title="Copy the sealed bytes to gdrive:AEGIS-AGPL/records and write the Drive link beside the local record. The key never travels."
|
|
692
|
+
>
|
|
693
|
+
<input type="checkbox" id="fp-publish-on-mint" />
|
|
694
|
+
<span>publish to the vault when sealing</span>
|
|
695
|
+
</label>
|
|
696
|
+
</div>
|
|
697
|
+
|
|
698
|
+
<div class="fp-actions">
|
|
699
|
+
<button type="button" id="fp-seal" class="ghost-btn">Seal this session</button>
|
|
700
|
+
<button type="button" id="fp-verify" class="ghost-btn">Verify</button>
|
|
701
|
+
<button type="button" id="fp-publish" class="ghost-btn">Publish</button>
|
|
702
|
+
<button type="button" id="fp-anchor" class="ghost-btn">Check anchor</button>
|
|
703
|
+
<button type="button" id="fp-vault" class="ghost-btn">List vault</button>
|
|
704
|
+
<button type="button" id="fp-audit" class="ghost-btn">Audit vault</button>
|
|
705
|
+
</div>
|
|
706
|
+
|
|
707
|
+
<!-- The auto-seal switch (settings.js `__autoMint`, off by default).
|
|
708
|
+
"Quiet", not "every turn": the timer restarts on every finished turn
|
|
709
|
+
and fires 90s after the LAST one, so one session files one record
|
|
710
|
+
that carries all of its turns — human axis and AI axis alike. A
|
|
711
|
+
manual "Seal now" resets the same counter, so a session is never
|
|
712
|
+
sealed twice. -->
|
|
713
|
+
<div class="fp-controls">
|
|
714
|
+
<label
|
|
715
|
+
class="flow-toggle"
|
|
716
|
+
for="fp-auto-seal"
|
|
717
|
+
title="Seal this session as a φ(α) record once it has been quiet for 90 seconds. Off by default. The seal happens whether or not anyone is watching, but publishing to the vault stays a separate checkbox."
|
|
718
|
+
>
|
|
719
|
+
<input type="checkbox" id="fp-auto-seal" />
|
|
720
|
+
<span id="fp-auto-seal-label">seal automatically when the session goes quiet</span>
|
|
721
|
+
</label>
|
|
722
|
+
<div class="fp-line">
|
|
723
|
+
<button type="button" id="fp-seal-now" class="ghost-btn">Seal now</button>
|
|
724
|
+
</div>
|
|
725
|
+
<p class="hint" id="fp-auto-hint"></p>
|
|
726
|
+
</div>
|
|
727
|
+
|
|
728
|
+
<!-- Class, not a style attribute: the window's CSP is `style-src 'self'`,
|
|
729
|
+
which drops inline styles silently — the same failure mode the
|
|
730
|
+
script-src note at the top of this file warns about for handlers. -->
|
|
731
|
+
<div class="fp-line fp-line-inset">
|
|
732
|
+
<input
|
|
733
|
+
type="text"
|
|
734
|
+
id="fp-backup-to"
|
|
735
|
+
placeholder="/path/for/a/key/copy"
|
|
736
|
+
autocomplete="off"
|
|
737
|
+
title="Copy the key out. Refused inside a git tree that does not ignore it, and never into cloud storage — a key in a synced folder can mint records indistinguishable from yours."
|
|
738
|
+
/>
|
|
739
|
+
<button type="button" id="fp-backup" class="ghost-btn">Back up key</button>
|
|
740
|
+
</div>
|
|
741
|
+
|
|
742
|
+
<ul class="fp-vault-list" id="fp-vault-list"></ul>
|
|
743
|
+
|
|
744
|
+
<div class="fp-out" id="fp-out">Ready. The key is never displayed.</div>
|
|
745
|
+
</div>
|
|
746
|
+
</div>
|
|
747
|
+
|
|
590
748
|
<script src="budget.js"></script>
|
|
591
749
|
<script src="usage.js"></script>
|
|
592
750
|
<script src="stream-policy.js"></script>
|
|
@@ -597,6 +755,10 @@
|
|
|
597
755
|
<script src="vendor/purify.min.js"></script>
|
|
598
756
|
<script src="vendor/aegis-highlight.js"></script>
|
|
599
757
|
<script src="markdown.js"></script>
|
|
758
|
+
<!-- Loaded before app.js: app.js's init() calls window.aegisFingerprint.mount
|
|
759
|
+
and composes its overlay into the Escape policy, and the orphan check in
|
|
760
|
+
test/renderer-wiring.test.mjs fails if this tag goes missing. -->
|
|
761
|
+
<script src="fingerprint.js"></script>
|
|
600
762
|
<script src="app.js"></script>
|
|
601
763
|
</body>
|
|
602
764
|
</html>
|
package/vendor/aegis.js
CHANGED
|
@@ -116,6 +116,26 @@ function randomUUID() {
|
|
|
116
116
|
const DEFAULT_API_BASE = 'https://aegiscloud.org';
|
|
117
117
|
const CLIENT_VERSION = '3.2.0';
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Every non-streaming request to aegis1 is bounded, and so is its body.
|
|
121
|
+
*
|
|
122
|
+
* The stream path has always had an idle watchdog (`readWithIdleTimeout`), but
|
|
123
|
+
* the plain `apiPost`/`apiGet` calls — account state, memory, models, billing —
|
|
124
|
+
* had none: `fetch` on a connection the server accepted and then never answered
|
|
125
|
+
* waits forever. That is not hypothetical for this backend. aegis1 runs under
|
|
126
|
+
* `gunicorn --worker-class gevent`, where a greenlet blocked on an upstream
|
|
127
|
+
* provider call can hold a request open with no reply and no error, and the
|
|
128
|
+
* host that called it hangs with no way out but force-quit. A caller-supplied
|
|
129
|
+
* signal (if any) still works; this only adds a ceiling where there was none.
|
|
130
|
+
*/
|
|
131
|
+
const DEFAULT_TIMEOUT_MS = 60000;
|
|
132
|
+
/**
|
|
133
|
+
* Cap on a single response body. A non-streaming endpoint that answers with an
|
|
134
|
+
* unbounded body (a misrouted download, a proxy error page echoing a request
|
|
135
|
+
* loop) would otherwise be read entirely into memory by `res.text()`.
|
|
136
|
+
*/
|
|
137
|
+
const MAX_BODY_BYTES = 8 * 1024 * 1024;
|
|
138
|
+
|
|
119
139
|
/**
|
|
120
140
|
* .mcp.json / Electron pass config as "${VAR}" template refs. When the var is
|
|
121
141
|
* unset, hosts have been observed to leave the template literally unexpanded
|
|
@@ -141,8 +161,127 @@ function createClient(opts = {}) {
|
|
|
141
161
|
// hence the allowlist rather than trusting the caller.
|
|
142
162
|
const channel = opts.channel === 'cli' ? 'cli' : 'desktop';
|
|
143
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Strip the live API key out of anything on its way to a message, a log or a
|
|
166
|
+
* renderer. Server error bodies echo the offending request often enough that
|
|
167
|
+
* this is not theoretical: a 400 that repeats the request payload puts the
|
|
168
|
+
* key into `err.message`, which hosts print to a terminal, a log file, and
|
|
169
|
+
* (desktop) across an IPC boundary. The key is the one value in this module
|
|
170
|
+
* whose leak is unrecoverable, so it is scrubbed at the boundary rather than
|
|
171
|
+
* trusted not to appear.
|
|
172
|
+
*/
|
|
173
|
+
function redact(value) {
|
|
174
|
+
if (!apiKey || typeof value !== 'string') return value;
|
|
175
|
+
return value.split(apiKey).join('[redacted]');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* A bounded `fetch`: caller signal where there is one, an aborting timeout
|
|
180
|
+
* where there is not. Returns the caller's abort untouched (so a host that
|
|
181
|
+
* cancelled a turn still sees its own cancellation, not a timeout), and
|
|
182
|
+
* raises a typed `timeout` error for the ceiling it added itself.
|
|
183
|
+
*/
|
|
184
|
+
async function boundedFetch(url, init) {
|
|
185
|
+
const callerSignal = init && init.signal;
|
|
186
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
187
|
+
let timer = null;
|
|
188
|
+
const onAbort = () => {
|
|
189
|
+
if (controller) controller.abort();
|
|
190
|
+
};
|
|
191
|
+
if (controller) {
|
|
192
|
+
// `unref` where available: a pending timeout must never be the reason a
|
|
193
|
+
// short-lived CLI process (`aegiscode -p "..."`) refuses to exit.
|
|
194
|
+
timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
195
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
196
|
+
if (callerSignal) {
|
|
197
|
+
if (callerSignal.aborted) controller.abort();
|
|
198
|
+
else if (typeof callerSignal.addEventListener === 'function') {
|
|
199
|
+
callerSignal.addEventListener('abort', onAbort, { once: true });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
return await fetch(url, {
|
|
205
|
+
...(init || {}),
|
|
206
|
+
signal: controller ? controller.signal : callerSignal,
|
|
207
|
+
});
|
|
208
|
+
} catch (err) {
|
|
209
|
+
// Only claim a timeout when *we* aborted. A caller-originated abort is
|
|
210
|
+
// the caller's business and is rethrown as-is.
|
|
211
|
+
const ours =
|
|
212
|
+
controller && controller.signal.aborted && !(callerSignal && callerSignal.aborted);
|
|
213
|
+
if (ours) {
|
|
214
|
+
const e = new Error(
|
|
215
|
+
`Timed out after ${timeoutMs / 1000}s waiting for ${apiBase}.`
|
|
216
|
+
);
|
|
217
|
+
e.status = 0;
|
|
218
|
+
e.code = 'timeout';
|
|
219
|
+
e.data = null;
|
|
220
|
+
throw e;
|
|
221
|
+
}
|
|
222
|
+
throw err;
|
|
223
|
+
} finally {
|
|
224
|
+
if (timer) clearTimeout(timer);
|
|
225
|
+
if (controller && callerSignal && typeof callerSignal.removeEventListener === 'function') {
|
|
226
|
+
callerSignal.removeEventListener('abort', onAbort);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Read a response body, refusing to buffer more than MAX_BODY_BYTES. */
|
|
232
|
+
async function readCappedText(res) {
|
|
233
|
+
if (!res || !res.body || typeof res.body.getReader !== 'function') {
|
|
234
|
+
const text = await res.text();
|
|
235
|
+
return text.length > maxBodyBytes ? text.slice(0, maxBodyBytes) : text;
|
|
236
|
+
}
|
|
237
|
+
const reader = res.body.getReader();
|
|
238
|
+
const decoder = new TextDecoder();
|
|
239
|
+
let out = '';
|
|
240
|
+
let total = 0;
|
|
241
|
+
try {
|
|
242
|
+
for (;;) {
|
|
243
|
+
const { done, value } = await reader.read();
|
|
244
|
+
if (done) break;
|
|
245
|
+
total += value ? value.byteLength : 0;
|
|
246
|
+
if (total > maxBodyBytes) {
|
|
247
|
+
const e = new Error(
|
|
248
|
+
`Response from ${apiBase} exceeded the ${maxBodyBytes / (1024 * 1024)}MB cap.`
|
|
249
|
+
);
|
|
250
|
+
e.status = res.status;
|
|
251
|
+
e.code = 'body_too_large';
|
|
252
|
+
e.data = null;
|
|
253
|
+
throw e;
|
|
254
|
+
}
|
|
255
|
+
out += decoder.decode(value, { stream: true });
|
|
256
|
+
}
|
|
257
|
+
out += decoder.decode();
|
|
258
|
+
return out;
|
|
259
|
+
} finally {
|
|
260
|
+
// Release the socket on every path, including the cap trip and any
|
|
261
|
+
// decoder throw — otherwise a rejected body keeps the connection alive.
|
|
262
|
+
try {
|
|
263
|
+
await reader.cancel();
|
|
264
|
+
} catch {
|
|
265
|
+
/* already released */
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
144
270
|
let memoryTokenCache = null;
|
|
145
271
|
|
|
272
|
+
// Injectable ceilings. A guard that can only be exercised by sleeping for a
|
|
273
|
+
// minute, or by allocating 9MB, is a guard that quietly rots — so the
|
|
274
|
+
// transport takes both limits as options (and an env override, so a host can
|
|
275
|
+
// tighten them without a code change). The defaults are the production ones.
|
|
276
|
+
const timeoutMs =
|
|
277
|
+
Number(opts.timeoutMs) > 0
|
|
278
|
+
? Number(opts.timeoutMs)
|
|
279
|
+
: Number(envVar('AEGIS_HTTP_TIMEOUT_MS')) > 0
|
|
280
|
+
? Number(envVar('AEGIS_HTTP_TIMEOUT_MS'))
|
|
281
|
+
: DEFAULT_TIMEOUT_MS;
|
|
282
|
+
const maxBodyBytes =
|
|
283
|
+
Number(opts.maxBodyBytes) > 0 ? Number(opts.maxBodyBytes) : MAX_BODY_BYTES;
|
|
284
|
+
|
|
146
285
|
/**
|
|
147
286
|
* A campaign-tagged upgrade link. Never hand-build a `/subscribe` URL in a
|
|
148
287
|
* prompt: the bare form carries no attribution, so the click arrives as
|
|
@@ -190,7 +329,7 @@ function createClient(opts = {}) {
|
|
|
190
329
|
}
|
|
191
330
|
|
|
192
331
|
async function apiPost(path, body, headers, opts) {
|
|
193
|
-
const res = await
|
|
332
|
+
const res = await boundedFetch(`${apiBase}${path}`, {
|
|
194
333
|
method: 'POST',
|
|
195
334
|
headers: headers || authHeaders(),
|
|
196
335
|
body: JSON.stringify(body || {}),
|
|
@@ -199,10 +338,11 @@ function createClient(opts = {}) {
|
|
|
199
338
|
return parseResponse(res);
|
|
200
339
|
}
|
|
201
340
|
|
|
202
|
-
async function apiGet(path, headers) {
|
|
203
|
-
const res = await
|
|
341
|
+
async function apiGet(path, headers, opts) {
|
|
342
|
+
const res = await boundedFetch(`${apiBase}${path}`, {
|
|
204
343
|
method: 'GET',
|
|
205
344
|
headers: headers || authHeaders(),
|
|
345
|
+
signal: opts && opts.signal,
|
|
206
346
|
});
|
|
207
347
|
return parseResponse(res);
|
|
208
348
|
}
|
|
@@ -265,12 +405,14 @@ function createClient(opts = {}) {
|
|
|
265
405
|
}
|
|
266
406
|
|
|
267
407
|
async function parseResponse(res) {
|
|
268
|
-
const text = await res
|
|
408
|
+
const text = await readCappedText(res);
|
|
269
409
|
let data;
|
|
270
410
|
try {
|
|
271
411
|
data = text ? JSON.parse(text) : {};
|
|
272
412
|
} catch {
|
|
273
|
-
|
|
413
|
+
// An unparseable body is often a proxy/error page that quotes the
|
|
414
|
+
// request, so it goes through redaction too.
|
|
415
|
+
data = { raw: redact(text) };
|
|
274
416
|
}
|
|
275
417
|
if (!res.ok) {
|
|
276
418
|
if (isVersionGate(res.status, data)) {
|
|
@@ -279,7 +421,7 @@ function createClient(opts = {}) {
|
|
|
279
421
|
const msg =
|
|
280
422
|
(data && (data.error?.message || data.error || data.message)) ||
|
|
281
423
|
`HTTP ${res.status}`;
|
|
282
|
-
const err = new Error(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
|
424
|
+
const err = new Error(redact(typeof msg === 'string' ? msg : JSON.stringify(msg)));
|
|
283
425
|
err.status = res.status;
|
|
284
426
|
err.data = data;
|
|
285
427
|
throw err;
|
|
@@ -314,7 +456,7 @@ function createClient(opts = {}) {
|
|
|
314
456
|
if (data && data.code === 'version_mismatch') {
|
|
315
457
|
throw versionGateError(data, status || 426);
|
|
316
458
|
}
|
|
317
|
-
const msg = errorMessageOf(data);
|
|
459
|
+
const msg = redact(errorMessageOf(data));
|
|
318
460
|
if (!msg) return;
|
|
319
461
|
const err = new Error(msg);
|
|
320
462
|
err.status = status || 500;
|
|
@@ -589,7 +731,13 @@ function createClient(opts = {}) {
|
|
|
589
731
|
async function postStream(path, body, headers, onStream, signal, streamOpts) {
|
|
590
732
|
const { onReasoning, idleTimeoutMs, retryWithoutStreamOptions } = streamOpts || {};
|
|
591
733
|
const request = (payload) =>
|
|
592
|
-
fetch
|
|
734
|
+
// `boundedFetch` here bounds time-to-*headers* only: fetch resolves when
|
|
735
|
+
// the status line lands, and the timer is cleared in its `finally`, so a
|
|
736
|
+
// legitimately long generation is never cut off by it. What it does fix is
|
|
737
|
+
// the gap the idle watchdog cannot reach — a server that accepts the
|
|
738
|
+
// socket and never sends response headers, where `reader.read()` has not
|
|
739
|
+
// been reached yet and so nothing is watching at all.
|
|
740
|
+
boundedFetch(`${apiBase}${path}`, {
|
|
593
741
|
method: 'POST',
|
|
594
742
|
headers,
|
|
595
743
|
body: JSON.stringify({ ...payload, stream: true }),
|
|
@@ -611,7 +759,9 @@ function createClient(opts = {}) {
|
|
|
611
759
|
if (!res.ok && res.status === 426) {
|
|
612
760
|
let gateData = {};
|
|
613
761
|
try {
|
|
614
|
-
|
|
762
|
+
// Capped like every other body read: a gate response is still a
|
|
763
|
+
// response from a host we do not control the size of.
|
|
764
|
+
const t = await readCappedText(res);
|
|
615
765
|
gateData = t ? JSON.parse(t) : {};
|
|
616
766
|
} catch {
|
|
617
767
|
gateData = {}; // a gate with an unreadable body is still a gate
|
|
@@ -646,7 +796,12 @@ function createClient(opts = {}) {
|
|
|
646
796
|
// it on would turn this graceful fallback into a second rejection.
|
|
647
797
|
const cleanBody = { ...body };
|
|
648
798
|
delete cleanBody.stream_options;
|
|
649
|
-
|
|
799
|
+
// `signal` is forwarded here too: this is the *non-stream fallback* of a
|
|
800
|
+
// stream call, i.e. the path taken when the server answered a streaming
|
|
801
|
+
// request with plain JSON. Dropping the signal here meant an aborted turn
|
|
802
|
+
// kept waiting on a request nobody was listening for any more — the
|
|
803
|
+
// caller had already moved on, and the host held the socket.
|
|
804
|
+
const data = await apiPost(path, { ...cleanBody, stream: false }, headers, { signal });
|
|
650
805
|
const fullText = textOf(data);
|
|
651
806
|
if (fullText) onStream({ delta: fullText });
|
|
652
807
|
return data;
|
|
@@ -761,6 +916,7 @@ function createClient(opts = {}) {
|
|
|
761
916
|
typeof e === 'string'
|
|
762
917
|
? e
|
|
763
918
|
: errorMessageOf({ error: e }) || 'stream error';
|
|
919
|
+
sseError = redact(sseError);
|
|
764
920
|
}
|
|
765
921
|
continue;
|
|
766
922
|
}
|
|
@@ -861,12 +1017,18 @@ function createClient(opts = {}) {
|
|
|
861
1017
|
return result;
|
|
862
1018
|
}
|
|
863
1019
|
|
|
864
|
-
|
|
865
|
-
|
|
1020
|
+
/**
|
|
1021
|
+
* The read-only GETs take an optional `opts` so a host can cancel them.
|
|
1022
|
+
* Without it no non-streaming call in this module could be aborted at all:
|
|
1023
|
+
* the signal had nowhere to travel, so `boundedFetch` always fell back to its
|
|
1024
|
+
* own ceiling and a caller's cancel was silently ignored.
|
|
1025
|
+
*/
|
|
1026
|
+
async function listModels(opts) {
|
|
1027
|
+
return apiGet('/api/v1/models', null, opts);
|
|
866
1028
|
}
|
|
867
1029
|
|
|
868
|
-
async function tokenBankBalance() {
|
|
869
|
-
return apiGet('/api/token-bank/balance');
|
|
1030
|
+
async function tokenBankBalance(opts) {
|
|
1031
|
+
return apiGet('/api/token-bank/balance', null, opts);
|
|
870
1032
|
}
|
|
871
1033
|
|
|
872
1034
|
/** Start a token-bank top-up; resolves to { url } for the payment page.
|
|
@@ -884,8 +1046,8 @@ function createClient(opts = {}) {
|
|
|
884
1046
|
return apiPost('/api/billing/checkout', {});
|
|
885
1047
|
}
|
|
886
1048
|
|
|
887
|
-
async function byokStatus() {
|
|
888
|
-
return apiGet('/api/user/api-keys');
|
|
1049
|
+
async function byokStatus(opts) {
|
|
1050
|
+
return apiGet('/api/user/api-keys', null, opts);
|
|
889
1051
|
}
|
|
890
1052
|
|
|
891
1053
|
/** The server's BYOK provider catalog: which providers accept a key, which
|
|
@@ -893,8 +1055,8 @@ function createClient(opts = {}) {
|
|
|
893
1055
|
* starts with. Deliberately NOT key-gated — "which key do I go get, and
|
|
894
1056
|
* what will it unlock?" is the question asked *before* an AEGIS key exists.
|
|
895
1057
|
* An AEGIS key, if present, only adds the `configured` column. */
|
|
896
|
-
async function byokProviders() {
|
|
897
|
-
return apiGet('/api/v1/byok/providers');
|
|
1058
|
+
async function byokProviders(opts) {
|
|
1059
|
+
return apiGet('/api/v1/byok/providers', null, opts);
|
|
898
1060
|
}
|
|
899
1061
|
|
|
900
1062
|
async function byokSet(provider, providerApiKey) {
|