aegis-desktop 1.0.13 → 1.0.14
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/google-connect.js +68 -10
- package/lib/local/engine.js +74 -1
- package/lib/local/entitlement.js +7 -1
- package/main.js +261 -122
- package/package.json +1 -4
- package/preload.js +13 -5
- package/renderer/app.js +102 -2
- package/renderer/fingerprint.js +17 -1
- package/vendor/aegis.js +128 -3
- package/vendor/drive-connect.js +28 -3
- package/vendor/google-signin.js +12 -4
- package/vendor/model-discovery.js +43 -0
package/renderer/app.js
CHANGED
|
@@ -1187,8 +1187,14 @@ function renderUpdateBanner(state) {
|
|
|
1187
1187
|
// Download button — `canDownload` is true only when it has an argv to run.
|
|
1188
1188
|
// The rest (npx, a source checkout) have nothing to install, so the banner
|
|
1189
1189
|
// names the command instead of offering a button that would do nothing.
|
|
1190
|
+
// `commandIsSentence` is the main process saying this channel has no
|
|
1191
|
+
// command to type — a packaged build, an npx run, a source checkout. Those
|
|
1192
|
+
// get the sentence as written; prefixing "run:" to prose reads as a command
|
|
1193
|
+
// that does not exist.
|
|
1190
1194
|
text = state.command
|
|
1191
|
-
?
|
|
1195
|
+
? (state.commandIsSentence
|
|
1196
|
+
? `Update ${version}available. ${state.command}`
|
|
1197
|
+
: `Update ${version}available — run: ${state.command}`)
|
|
1192
1198
|
: `Update ${version}available.`;
|
|
1193
1199
|
showDownload = !!state.canDownload;
|
|
1194
1200
|
if (showDownload) text = `Update ${version}available.`;
|
|
@@ -3075,6 +3081,13 @@ async function applyWelcomeAiLine() {
|
|
|
3075
3081
|
tools: false,
|
|
3076
3082
|
singlePass: true,
|
|
3077
3083
|
effort: 'low',
|
|
3084
|
+
// A sentence the user never asked for, asked for on boot while the window
|
|
3085
|
+
// is still painting. If the provider stalls past its budget, main resolves
|
|
3086
|
+
// this with `bestEffortFailed` and logs one classified line instead of
|
|
3087
|
+
// Electron printing "Error occurred in handler for 'model:chat'" plus a
|
|
3088
|
+
// provider stack as the first thing the app says. The catch below already
|
|
3089
|
+
// treats "no answer" as "keep the fact line".
|
|
3090
|
+
bestEffort: true,
|
|
3078
3091
|
};
|
|
3079
3092
|
if (modelId) payload.model = modelId;
|
|
3080
3093
|
|
|
@@ -3723,11 +3736,38 @@ async function spawnPath(card, spec) {
|
|
|
3723
3736
|
// one provider call, always.
|
|
3724
3737
|
singlePass: true,
|
|
3725
3738
|
sessionId: id,
|
|
3739
|
+
// A discovery path is a background dispatch beside the answer the user
|
|
3740
|
+
// actually waited for. When it stalls, the catch below already renders
|
|
3741
|
+
// "Path unavailable: …" into its own card — that IS the failure UI. So
|
|
3742
|
+
// it is marked best-effort: main resolves a classified payload rather
|
|
3743
|
+
// than rejecting, and the terminal gets one line instead of Electron's
|
|
3744
|
+
// raw "Error occurred in handler for 'model:chat'" stack for a card the
|
|
3745
|
+
// user can already see is stopped.
|
|
3746
|
+
bestEffort: true,
|
|
3726
3747
|
},
|
|
3727
3748
|
onDelta
|
|
3728
3749
|
);
|
|
3729
3750
|
|
|
3730
3751
|
const choice = (data && data.choices && data.choices[0]) || {};
|
|
3752
|
+
// `bestEffort` above means a stalled lane RESOLVES instead of rejecting,
|
|
3753
|
+
// so the throw-handler at the bottom of this function no longer runs for
|
|
3754
|
+
// it. Without this branch a timeout would render as a successful card
|
|
3755
|
+
// reading "(no path found)" — claiming the model looked and found nothing
|
|
3756
|
+
// when in fact it never answered. Say which one happened.
|
|
3757
|
+
if (data && data.bestEffortFailed) {
|
|
3758
|
+
throw new Error(data.message || 'the provider did not answer');
|
|
3759
|
+
}
|
|
3760
|
+
// Same rebuild as the turn path: a card's own stop button aborts this
|
|
3761
|
+
// lane's session, and that abort now resolves instead of rejecting. Left
|
|
3762
|
+
// unhandled it would fall through to the success branch and mark a
|
|
3763
|
+
// cancelled card "done" — claiming a path completed when the user cut it
|
|
3764
|
+
// off. The catch below already renders it stopped.
|
|
3765
|
+
if (data && data.aborted) {
|
|
3766
|
+
const stop = new Error(data.message || 'This operation was aborted');
|
|
3767
|
+
stop.name = 'AbortError';
|
|
3768
|
+
stop.code = 'ABORT_ERR';
|
|
3769
|
+
throw stop;
|
|
3770
|
+
}
|
|
3731
3771
|
const text =
|
|
3732
3772
|
(choice.message && choice.message.content) || streamed || '(no path found)';
|
|
3733
3773
|
renderMessageBody(body, 'assistant', text);
|
|
@@ -4336,6 +4376,16 @@ async function loadModels(cls) {
|
|
|
4336
4376
|
// which serve Ollama's /api/tags, so the user can still type a model id.
|
|
4337
4377
|
const needsDaemon = Boolean(data && data.needsDaemon);
|
|
4338
4378
|
const listed = !(data && data.listed === false);
|
|
4379
|
+
// BYOK's two states the other classes cannot have, both reported by
|
|
4380
|
+
// engine.listModels so this file never has to infer them. `providersFailed`
|
|
4381
|
+
// means the CATALOG CALL failed — distinct from a catalog that answered
|
|
4382
|
+
// with nothing, and distinct from `listed` above (that one is the local
|
|
4383
|
+
// class's tag list). `unmatchedProviders` names declarations in
|
|
4384
|
+
// `models.json` for providers the catalog does not list.
|
|
4385
|
+
const providersFailed = Boolean(data && data.providersFailed);
|
|
4386
|
+
const unmatchedProviders = Array.isArray(data && data.unmatchedProviders)
|
|
4387
|
+
? data.unmatchedProviders
|
|
4388
|
+
: [];
|
|
4339
4389
|
// Typed tag: shown only when the picker has nothing to offer, which is
|
|
4340
4390
|
// precisely the state the transport's own error message names ("pick one
|
|
4341
4391
|
// from the model list, or type a tag"). When the daemon hands us a list the
|
|
@@ -4364,6 +4414,17 @@ async function loadModels(cls) {
|
|
|
4364
4414
|
// surface is plain OpenAI-compatible, so a typed tag works.
|
|
4365
4415
|
hint = 'That address answered but listed no models — not Ollama? ' +
|
|
4366
4416
|
'Type the model tag in the box above and send.';
|
|
4417
|
+
} else if (cls === 'byok' && providersFailed) {
|
|
4418
|
+
// The catalog call itself failed — offline, a 5xx, a relay hiccup. The
|
|
4419
|
+
// empty picker below would otherwise render as "No models listed.", which
|
|
4420
|
+
// reads as an account with nothing configured and would hide the reason
|
|
4421
|
+
// the user actually needs. Nothing is lost: the saved provider key and
|
|
4422
|
+
// the `models.json` declaration live on disk and come back on Refresh.
|
|
4423
|
+
// Placed BEFORE the empty-list branch because an empty list is the
|
|
4424
|
+
// symptom here, not the state.
|
|
4425
|
+
hint = 'Provider catalog unreachable — your saved keys and declared ' +
|
|
4426
|
+
'models are unaffected. Refresh to try again.' +
|
|
4427
|
+
(data && data.providersError ? ` (${data.providersError})` : '');
|
|
4367
4428
|
} else if (!list.length) {
|
|
4368
4429
|
// The Aegis Cloud class offers no model to pin (engine.js's aegis branch
|
|
4369
4430
|
// omits the aegiscloud ids on purpose), so an empty list there is the
|
|
@@ -4411,6 +4472,21 @@ async function loadModels(cls) {
|
|
|
4411
4472
|
} else {
|
|
4412
4473
|
hint = `${list.length} model${list.length === 1 ? '' : 's'} available.`;
|
|
4413
4474
|
}
|
|
4475
|
+
// Declarations for providers the catalog does not name (typo, or a provider
|
|
4476
|
+
// renamed/dropped server-side) are never turned into a row — `withUserModels`
|
|
4477
|
+
// refuses to fabricate one, because it would 401 on its first turn. Saying
|
|
4478
|
+
// so is the alternative to silence: the model is still in `models.json` and
|
|
4479
|
+
// the user would otherwise just notice it missing. Appended to whatever
|
|
4480
|
+
// state the list is in, including a successful one.
|
|
4481
|
+
//
|
|
4482
|
+
// NOT appended when the catalog itself failed: every declaration is
|
|
4483
|
+
// "unmatched" against a list that never arrived, and naming them as bad
|
|
4484
|
+
// would send the user to fix provider names that are perfectly correct.
|
|
4485
|
+
if (cls === 'byok' && !providersFailed && unmatchedProviders.length) {
|
|
4486
|
+
const names = unmatchedProviders.join(', ');
|
|
4487
|
+
hint = `${hint} Not shown: ${names} — declared in models.json, but not in ` +
|
|
4488
|
+
'the provider catalog. Check the provider name in Settings.';
|
|
4489
|
+
}
|
|
4414
4490
|
// Display-only: what this model says its own output limit is. It sizes no
|
|
4415
4491
|
// request — budgetFor() answers that from the Effort rung.
|
|
4416
4492
|
const ceiling = maxTokensCeiling(modelMeta.get(currentModelId()));
|
|
@@ -4791,7 +4867,9 @@ async function loadSettings() {
|
|
|
4791
4867
|
text.textContent =
|
|
4792
4868
|
`BYOK handling fee: ${per1k(fee.in_usd_per_1k)} / 1k in, ` +
|
|
4793
4869
|
`${per1k(fee.out_usd_per_1k)} / 1k out — billed to your AEGIS account ` +
|
|
4794
|
-
'on top of your own provider bill.'
|
|
4870
|
+
'on top of your own provider bill. This is the published rate, not a ' +
|
|
4871
|
+
'personal quote — Unlimited subscribers pay no handling fee at all, ' +
|
|
4872
|
+
'and neither do users on the free 1-month Unlimited trial.';
|
|
4795
4873
|
note.appendChild(text);
|
|
4796
4874
|
els.settingsList.appendChild(note);
|
|
4797
4875
|
}
|
|
@@ -5807,6 +5885,21 @@ async function send() {
|
|
|
5807
5885
|
onDelta
|
|
5808
5886
|
);
|
|
5809
5887
|
|
|
5888
|
+
// A stop that crossed IPC arrives as a resolved payload, not a rejection:
|
|
5889
|
+
// main cannot let the abort reject, because `ipcMain.handle` prints every
|
|
5890
|
+
// rejection with a full stack and a user-pressed stop is not a crash (see
|
|
5891
|
+
// the `aborted` note in registerModelIpc). Rebuild the AbortError here so
|
|
5892
|
+
// the catch below runs EXACTLY as it did before — `isCancellation` →
|
|
5893
|
+
// `salvageTurn` → a kept, half-written answer labelled "stopped by you".
|
|
5894
|
+
// Without this the salvaged text would be thrown away and the stop would
|
|
5895
|
+
// read as a completed turn with an empty reply.
|
|
5896
|
+
if (data && data.aborted) {
|
|
5897
|
+
const stop = new Error(data.message || 'This operation was aborted');
|
|
5898
|
+
stop.name = 'AbortError';
|
|
5899
|
+
stop.code = 'ABORT_ERR';
|
|
5900
|
+
throw stop;
|
|
5901
|
+
}
|
|
5902
|
+
|
|
5810
5903
|
const choice = (data && data.choices && data.choices[0]) || {};
|
|
5811
5904
|
const text =
|
|
5812
5905
|
(choice.message && choice.message.content) ||
|
|
@@ -6178,6 +6271,13 @@ async function init() {
|
|
|
6178
6271
|
if (fpPanel && typeof fpPanel.open === 'function') fpPanel.open();
|
|
6179
6272
|
});
|
|
6180
6273
|
}
|
|
6274
|
+
// Account > Google Drive Backup… — same panel, scrolled straight to the
|
|
6275
|
+
// Drive row instead of leaving Sign in with Google to be found by hand.
|
|
6276
|
+
if (aegis.onMenuGoogleDrive) {
|
|
6277
|
+
aegis.onMenuGoogleDrive(() => {
|
|
6278
|
+
if (fpPanel && typeof fpPanel.open === 'function') fpPanel.open({ focus: 'drive' });
|
|
6279
|
+
});
|
|
6280
|
+
}
|
|
6181
6281
|
if (aegis.onMenuExportMarkdown) aegis.onMenuExportMarkdown(() => exportSession('markdown'));
|
|
6182
6282
|
if (aegis.onMenuExportJson) aegis.onMenuExportJson(() => exportSession('json'));
|
|
6183
6283
|
// aegis:// deep link (main.js sendDeepLinkToWindow) — delivered once the
|
package/renderer/fingerprint.js
CHANGED
|
@@ -621,7 +621,7 @@
|
|
|
621
621
|
return Boolean(o) && !o.hidden;
|
|
622
622
|
};
|
|
623
623
|
|
|
624
|
-
async function open() {
|
|
624
|
+
async function open(opts) {
|
|
625
625
|
const o = el(IDS.overlay);
|
|
626
626
|
if (!o) return;
|
|
627
627
|
o.hidden = false;
|
|
@@ -630,6 +630,22 @@
|
|
|
630
630
|
if (d.body && d.body.classList) d.body.classList.add('fp-open');
|
|
631
631
|
await refresh();
|
|
632
632
|
await doVault();
|
|
633
|
+
// The Google Drive row sits at the bottom of a panel most of whose
|
|
634
|
+
// controls are unrelated (the φ(α) key). The top-menu entry for it
|
|
635
|
+
// (main.js's "Google Drive Backup…") opens straight here instead of
|
|
636
|
+
// leaving the user to find it among Seal/Verify/Publish/Vault.
|
|
637
|
+
if (opts && opts.focus === 'drive') {
|
|
638
|
+
const row = el(IDS.driveStatus);
|
|
639
|
+
if (row && row.scrollIntoView) row.scrollIntoView({ block: 'center' });
|
|
640
|
+
// Whichever Drive action is actually clickable right now — sign-in
|
|
641
|
+
// first, then connect, then sync — so focus lands on the next step
|
|
642
|
+
// instead of a disabled button.
|
|
643
|
+
const next = [IDS.driveSignIn, IDS.driveConnect, IDS.driveSyncNow]
|
|
644
|
+
.map(el)
|
|
645
|
+
.find((btn) => btn && !btn.disabled);
|
|
646
|
+
if (next) next.focus();
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
633
649
|
const f = el(IDS.close);
|
|
634
650
|
if (f) f.focus();
|
|
635
651
|
}
|
package/vendor/aegis.js
CHANGED
|
@@ -489,6 +489,47 @@ const TRANSIENT_STREAM_ERROR_PATTERN =
|
|
|
489
489
|
const TRANSIENT_STREAM_RETRIES = 2;
|
|
490
490
|
const TRANSIENT_STREAM_BASE_WAIT_MS = 500;
|
|
491
491
|
|
|
492
|
+
/**
|
|
493
|
+
* The sentence aegis1 puts in a mid-stream error frame when the UPSTREAM
|
|
494
|
+
* provider call it was relaying hit its own deadline. Deliberately kept out of
|
|
495
|
+
* TRANSIENT_STREAM_ERROR_PATTERN: a socket reset is worth re-issuing at once,
|
|
496
|
+
* but a provider timeout has already spent a full deadline, so the same retry
|
|
497
|
+
* would make a stuck upstream cost three of them before anything was said.
|
|
498
|
+
*
|
|
499
|
+
* It still has to be CLASSIFIED, not passed through raw. The frame arrives
|
|
500
|
+
* after the stream's own 200 OK, so `res.status` is 200 — the throw used to be
|
|
501
|
+
* stamped with that and reached the desktop host as `{ status: 200 }`, which
|
|
502
|
+
* reads as a success to every downstream branch (engine.js keys its recovery
|
|
503
|
+
* on `status === 402`/`400`; the renderer keys "waited too long" on status 0).
|
|
504
|
+
*/
|
|
505
|
+
const SSE_UPSTREAM_TIMEOUT_PATTERN = /request timed out|timed out waiting|upstream timeout/i;
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* The message for a provider timeout, stating whose deadline expired.
|
|
509
|
+
*
|
|
510
|
+
* This used to read "the provider returned no answer within the 60s stream
|
|
511
|
+
* budget (raise AEGIS_SSE_IDLE_TIMEOUT_MS for a slower provider)". Measured
|
|
512
|
+
* live, that sentence was false in the one case it fired: the provider gave up
|
|
513
|
+
* in ~11-13s and aegis1 relayed it, so the client's own watchdog never fired at
|
|
514
|
+
* all — `sseError` is only ever set from a `json.error` frame, and our stall
|
|
515
|
+
* raises its own `stream stalled - …` error from `readWithIdleTimeout` instead.
|
|
516
|
+
* The advice pointed at an env var that provably cannot help, which is worse
|
|
517
|
+
* than saying nothing: a user follows it, restarts, and the turn still fails.
|
|
518
|
+
*
|
|
519
|
+
* So the message separates the two deadlines and names elapsed time, which is
|
|
520
|
+
* the fact that tells a reader which one it was without trusting the prose.
|
|
521
|
+
* `elapsedMs` is omitted when the caller has no measured start (the
|
|
522
|
+
* non-streaming path), rather than inventing one.
|
|
523
|
+
*/
|
|
524
|
+
function upstreamTimeoutMessage(base, elapsedMs) {
|
|
525
|
+
const took = Number(elapsedMs) > 0 ? ` after ${(Number(elapsedMs) / 1000).toFixed(1)}s` : '';
|
|
526
|
+
return (
|
|
527
|
+
`${base} — the upstream provider gave up before answering${took}. ` +
|
|
528
|
+
"That is the provider's own deadline, not this client's stream budget, so " +
|
|
529
|
+
'raising AEGIS_SSE_IDLE_TIMEOUT_MS cannot help'
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
492
533
|
/**
|
|
493
534
|
* .mcp.json / Electron pass config as "${VAR}" template refs. When the var is
|
|
494
535
|
* unset, hosts have been observed to leave the template literally unexpanded
|
|
@@ -982,7 +1023,32 @@ function createClient(opts = {}) {
|
|
|
982
1023
|
(data && (data.error?.message || data.error || data.message)) ||
|
|
983
1024
|
`HTTP ${res.status}`;
|
|
984
1025
|
const err = new Error(redact(typeof msg === 'string' ? msg : JSON.stringify(msg)));
|
|
985
|
-
|
|
1026
|
+
// The buffered twin of postStream's upstream-timeout classification.
|
|
1027
|
+
//
|
|
1028
|
+
// Aegis1 answers a NON-streaming request by draining the same generator
|
|
1029
|
+
// itself, so the provider's deadline arrives here as a plain 503 carrying
|
|
1030
|
+
// the same sentence a streaming caller gets as an error frame. Without
|
|
1031
|
+
// this the one failure had two shapes depending on wire form: the stream
|
|
1032
|
+
// path said `status 0 / code timeout` with an explanation, while the
|
|
1033
|
+
// buffered path said `status 503 / code undefined` and nothing else —
|
|
1034
|
+
// measured live, same provider, same 10s wall, ~10.8s either way. A host
|
|
1035
|
+
// gating on `code === 'timeout'` therefore saw it on one path only.
|
|
1036
|
+
//
|
|
1037
|
+
// Status becomes 0 for the same reason as the stream path: this is
|
|
1038
|
+
// "waited too long", which every downstream branch already reads as
|
|
1039
|
+
// status 0 + code, and a 5xx would instead be taken as a server fault.
|
|
1040
|
+
// The server's own number is preserved on `serverStatus`, because 5xx is
|
|
1041
|
+
// worth knowing even when it means the same thing to the caller.
|
|
1042
|
+
const upstreamTimeout =
|
|
1043
|
+
res.status >= 500 && res.status < 600 && SSE_UPSTREAM_TIMEOUT_PATTERN.test(err.message);
|
|
1044
|
+
if (upstreamTimeout) {
|
|
1045
|
+
err.serverStatus = res.status;
|
|
1046
|
+
err.status = 0;
|
|
1047
|
+
err.code = 'timeout';
|
|
1048
|
+
err.message = upstreamTimeoutMessage(err.message);
|
|
1049
|
+
} else {
|
|
1050
|
+
err.status = res.status;
|
|
1051
|
+
}
|
|
986
1052
|
err.data = data;
|
|
987
1053
|
throw err;
|
|
988
1054
|
}
|
|
@@ -1262,6 +1328,29 @@ function createClient(opts = {}) {
|
|
|
1262
1328
|
baseURL,
|
|
1263
1329
|
} = {}) {
|
|
1264
1330
|
const key = providerKey !== undefined ? providerKey : opts.providerKey;
|
|
1331
|
+
// `custom` is the one catalog provider whose address the server does NOT
|
|
1332
|
+
// know: every other id maps to a host aegis1 has baked in, and `custom`
|
|
1333
|
+
// travels as `base_url` on each request instead. So a custom call that
|
|
1334
|
+
// names no endpoint is not "a provider we can guess a default for" — there
|
|
1335
|
+
// is no default to guess, and aegis1 answers 400 (`_byok_custom_base_url`
|
|
1336
|
+
// refuses it). Refusing HERE turns that server-side 400 into a client-side
|
|
1337
|
+
// sentence naming the missing half, which is the difference between "custom
|
|
1338
|
+
// is broken" and "you saved a key but never saved the endpoint".
|
|
1339
|
+
//
|
|
1340
|
+
// Reachable without the card: `/byok-key custom <key>` saves a key alone,
|
|
1341
|
+
// and a hand-edited row can hold a key with no address. Guarded on the
|
|
1342
|
+
// value actually being absent (`!baseURL`), never on the provider alone, so
|
|
1343
|
+
// a correctly-saved custom row is untouched.
|
|
1344
|
+
if (provider === 'custom' && !baseURL) {
|
|
1345
|
+
const err = new Error(
|
|
1346
|
+
'the "custom" provider dials the endpoint you name, and this call names none — ' +
|
|
1347
|
+
'save the address first: /model-add custom <https-base-url> <api-key> <model-id>, ' +
|
|
1348
|
+
'or the endpoint field in Settings › Provider (custom).'
|
|
1349
|
+
);
|
|
1350
|
+
err.status = 400;
|
|
1351
|
+
err.code = 'custom_endpoint_missing';
|
|
1352
|
+
throw err;
|
|
1353
|
+
}
|
|
1265
1354
|
// Same rule as the pooled path above: omit `max_tokens` entirely when the
|
|
1266
1355
|
// caller states none, rather than inventing a default. The server treats a
|
|
1267
1356
|
// body max_tokens as a ceiling *over* its own ladder, so a defaulted 4096
|
|
@@ -1506,6 +1595,11 @@ function createClient(opts = {}) {
|
|
|
1506
1595
|
// moves `lastPayloadAt` below.
|
|
1507
1596
|
const idleMs = idleBudgetFor(res, idleTimeoutMs);
|
|
1508
1597
|
let lastPayloadAt = Date.now();
|
|
1598
|
+
// When this response began, for the one place that needs to tell the two
|
|
1599
|
+
// deadlines apart: a provider timeout frame carries no duration, and the
|
|
1600
|
+
// elapsed time is what separates "the upstream gave up" (seconds, well
|
|
1601
|
+
// inside our budget) from "our own watchdog fired" (the full budget).
|
|
1602
|
+
const streamStartedAt = lastPayloadAt;
|
|
1509
1603
|
let keepAlives = 0;
|
|
1510
1604
|
async function readWithIdleTimeout() {
|
|
1511
1605
|
let timer;
|
|
@@ -1684,8 +1778,39 @@ function createClient(opts = {}) {
|
|
|
1684
1778
|
_transientStreamAttempt: transientStreamAttempt + 1,
|
|
1685
1779
|
});
|
|
1686
1780
|
}
|
|
1687
|
-
|
|
1688
|
-
|
|
1781
|
+
// Deliberately NOT retried: this sentence is aegis1 relaying an upstream
|
|
1782
|
+
// abort ("Provider request timed out") as a mid-stream error frame with
|
|
1783
|
+
// the headers already 200 OK — the same frame shape as the reset above,
|
|
1784
|
+
// but the stall has already cost the caller a full provider deadline, and
|
|
1785
|
+
// re-issuing would make a stuck upstream cost three of them before the
|
|
1786
|
+
// host could say anything. One attempt, classified, and out.
|
|
1787
|
+
//
|
|
1788
|
+
// What must NOT happen is the shape this used to be thrown in: `res.status`
|
|
1789
|
+
// here is the SSE response's own 200 (the headers landed long before the
|
|
1790
|
+
// error frame did), so the rejection carried "success" as its status and
|
|
1791
|
+
// nothing downstream could classify it — the desktop host printed the raw
|
|
1792
|
+
// stack with `{ status: 200 }` for a turn that never produced a byte. A
|
|
1793
|
+
// timeout is stated in the shape every other "waited too long" on this
|
|
1794
|
+
// lane uses (status 0 + a code), which is what engine.js's `status === 402`
|
|
1795
|
+
// / `status === 400` branches already key off and what local.js translates
|
|
1796
|
+
// its own two clocks into — so "waited too long" stays one branch for
|
|
1797
|
+
// every class.
|
|
1798
|
+
//
|
|
1799
|
+
// Message: the budget is NOT named as the cause any more, because this
|
|
1800
|
+
// branch cannot be our own watchdog. `sseError` comes only from a
|
|
1801
|
+
// `json.error` frame the server sent; our watchdog raises `stream
|
|
1802
|
+
// stalled - …` out of readWithIdleTimeout and never reaches this code.
|
|
1803
|
+
// So every error here is the provider's deadline, already expired, and
|
|
1804
|
+
// the elapsed time says so as a fact rather than as prose — see
|
|
1805
|
+
// upstreamTimeoutMessage().
|
|
1806
|
+
const timedOut = SSE_UPSTREAM_TIMEOUT_PATTERN.test(sseError);
|
|
1807
|
+
const err = new Error(
|
|
1808
|
+
timedOut
|
|
1809
|
+
? upstreamTimeoutMessage(sseError, Date.now() - streamStartedAt)
|
|
1810
|
+
: sseError
|
|
1811
|
+
);
|
|
1812
|
+
err.status = timedOut ? 0 : res.status;
|
|
1813
|
+
if (timedOut) err.code = 'timeout';
|
|
1689
1814
|
throw err;
|
|
1690
1815
|
}
|
|
1691
1816
|
|
package/vendor/drive-connect.js
CHANGED
|
@@ -40,14 +40,39 @@ function driveConnectUrl(apiBase) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* One status check: is a Drive token on file for this account
|
|
43
|
+
* One status check: is a Drive token on file for this account, and is the
|
|
44
|
+
* account tied to a Google identity at all?
|
|
44
45
|
*
|
|
45
|
-
*
|
|
46
|
+
* `googleLinked` comes from the server (`/api/drive/token`'s `google_linked`,
|
|
47
|
+
* derived from `users.google_id`) and is NOT the same question as "does this
|
|
48
|
+
* machine have an API key". Every install auto-provisions an anonymous device
|
|
49
|
+
* key, so a key always exists while the account behind it may never have
|
|
50
|
+
* touched Google. Connecting Drive in that state attaches the tokens to
|
|
51
|
+
* whatever Google account the browser happens to hold — a different account
|
|
52
|
+
* than the one this machine polls — and the poll just times out. Callers need
|
|
53
|
+
* this flag to refuse that ordering.
|
|
54
|
+
*
|
|
55
|
+
* Older servers answer `google_linked` not at all; a connected account is
|
|
56
|
+
* proof enough (only a Google round-trip can store those tokens), so a
|
|
57
|
+
* missing flag next to a live token reads as linked rather than as a
|
|
58
|
+
* regression that would disable the button for everyone.
|
|
59
|
+
*
|
|
60
|
+
* @param {{driveToken: () => Promise<{connected:boolean, email?:string, google_linked?:boolean}>}} aegis
|
|
46
61
|
*/
|
|
47
62
|
async function driveStatus(aegis) {
|
|
48
63
|
const data = await aegis.driveToken();
|
|
64
|
+
const connected = Boolean(data && data.connected);
|
|
65
|
+
const raw = data && data.google_linked;
|
|
66
|
+
// Did the server actually answer the question? A server that predates the
|
|
67
|
+
// field omits it entirely, and an account with no Drive token answers 404
|
|
68
|
+
// with no flag — both look exactly like an explicit `false` once collapsed
|
|
69
|
+
// to a boolean. Callers that must not block a real sign-in need to tell
|
|
70
|
+
// "not linked" apart from "nobody asked the server".
|
|
71
|
+
const known = typeof raw === 'boolean';
|
|
49
72
|
return {
|
|
50
|
-
connected
|
|
73
|
+
connected,
|
|
74
|
+
googleLinked: connected || raw === true,
|
|
75
|
+
googleLinkedKnown: known || connected,
|
|
51
76
|
email: (data && data.email) || null,
|
|
52
77
|
};
|
|
53
78
|
}
|
package/vendor/google-signin.js
CHANGED
|
@@ -37,10 +37,14 @@ function escapeHtml(s) {
|
|
|
37
37
|
}[c]));
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
function page({ ok, error, username }) {
|
|
40
|
+
function page({ ok, error, username, surface }) {
|
|
41
41
|
const title = ok ? 'Signed in' : 'Sign-in failed';
|
|
42
|
+
// This page is shared by the CLI and the desktop, and only one of them is
|
|
43
|
+
// a terminal. The desktop passes `surface: 'app'`; the default keeps the
|
|
44
|
+
// CLI's existing wording.
|
|
45
|
+
const where = surface === 'app' ? 'the app' : 'the terminal';
|
|
42
46
|
const body = ok
|
|
43
|
-
? `Signed in${username ? ` as <b>${escapeHtml(username)}</b>` : ''}. You can close this tab and return to
|
|
47
|
+
? `Signed in${username ? ` as <b>${escapeHtml(username)}</b>` : ''}. You can close this tab and return to ${where}.`
|
|
44
48
|
: `Sign-in failed: ${escapeHtml(error || 'no token was returned')}. You can close this tab and try again.`;
|
|
45
49
|
return `<!doctype html><html><head><meta charset="utf-8"><title>${title} — AEGIS</title></head>` +
|
|
46
50
|
`<body style="font:16px sans-serif;max-width:32rem;margin:4rem auto;text-align:center;">` +
|
|
@@ -62,9 +66,13 @@ function buildAuthUrl(apiBase, callbackUrl) {
|
|
|
62
66
|
* else (a browser pre-connect, `/favicon.ico`) gets a plain 404 and the
|
|
63
67
|
* server keeps listening for the real one.
|
|
64
68
|
*
|
|
69
|
+
* `surface` only changes the wording of the page the browser lands on after
|
|
70
|
+
* signing in ('terminal' for the CLI, 'app' for the desktop) — it changes
|
|
71
|
+
* nothing about the flow itself.
|
|
72
|
+
*
|
|
65
73
|
* @returns {Promise<{url: string, wait: () => Promise<{apiKey:string, username:?string, memoryToken:?string}>}>}
|
|
66
74
|
*/
|
|
67
|
-
async function startGoogleSignIn({ apiBase, host = '127.0.0.1', timeoutMs = 5 * 60 * 1000 } = {}) {
|
|
75
|
+
async function startGoogleSignIn({ apiBase, host = '127.0.0.1', timeoutMs = 5 * 60 * 1000, surface = 'terminal' } = {}) {
|
|
68
76
|
if (!apiBase) throw new Error('startGoogleSignIn requires apiBase');
|
|
69
77
|
|
|
70
78
|
let settled = false;
|
|
@@ -93,7 +101,7 @@ async function startGoogleSignIn({ apiBase, host = '127.0.0.1', timeoutMs = 5 *
|
|
|
93
101
|
return;
|
|
94
102
|
}
|
|
95
103
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
96
|
-
res.end(page({ ok: Boolean(token), error, username }));
|
|
104
|
+
res.end(page({ ok: Boolean(token), error, username, surface }));
|
|
97
105
|
|
|
98
106
|
if (settled) return;
|
|
99
107
|
settled = true;
|
|
@@ -101,6 +101,36 @@ const ENDPOINTS = Object.freeze({
|
|
|
101
101
|
headers: bearer,
|
|
102
102
|
note: 'OpenAI-compatible',
|
|
103
103
|
}),
|
|
104
|
+
// The remaining providers in aegis1's catalog that speak `openai_chat` and
|
|
105
|
+
// answer a standard OpenAI-shaped list on `<base_url>/models`.
|
|
106
|
+
//
|
|
107
|
+
// Probed live rather than assumed, because "OpenAI-compatible chat" does not
|
|
108
|
+
// imply "has a list endpoint": cerebras, fireworks and sakana answer 401/403
|
|
109
|
+
// without a key (the endpoint exists and is gated) and NVIDIA's list answers
|
|
110
|
+
// 200 unauthenticated with 84 rows, which is why it is the one endpoint here
|
|
111
|
+
// whose list is public. Ollama is deliberately absent: it is keyless and
|
|
112
|
+
// local, so it has nothing to put in the API key this module requires, and
|
|
113
|
+
// `canDiscover` would be reporting a capability the caller cannot use.
|
|
114
|
+
cerebras: Object.freeze({
|
|
115
|
+
url: 'https://api.cerebras.ai/v1/models',
|
|
116
|
+
headers: bearer,
|
|
117
|
+
note: 'OpenAI-compatible under /v1',
|
|
118
|
+
}),
|
|
119
|
+
fireworks: Object.freeze({
|
|
120
|
+
url: 'https://api.fireworks.ai/inference/v1/models',
|
|
121
|
+
headers: bearer,
|
|
122
|
+
note: 'OpenAI-compatible under /inference/v1; ids are "accounts/fireworks/models/<id>"',
|
|
123
|
+
}),
|
|
124
|
+
nvidia_nim: Object.freeze({
|
|
125
|
+
url: 'https://integrate.api.nvidia.com/v1/models',
|
|
126
|
+
headers: bearer,
|
|
127
|
+
note: 'OpenAI-compatible; ids are "<vendor>/<model>"',
|
|
128
|
+
}),
|
|
129
|
+
sakana: Object.freeze({
|
|
130
|
+
url: 'https://api.sakana.ai/v1/models',
|
|
131
|
+
headers: bearer,
|
|
132
|
+
note: 'OpenAI-compatible under /v1',
|
|
133
|
+
}),
|
|
104
134
|
});
|
|
105
135
|
|
|
106
136
|
/** Other spellings a user types for a provider this module knows. */
|
|
@@ -112,6 +142,19 @@ const ALIASES = Object.freeze({
|
|
|
112
142
|
'googleai': 'gemini',
|
|
113
143
|
'open-ai': 'openai',
|
|
114
144
|
'deep-seek': 'deepseek',
|
|
145
|
+
// Two catalog rows that are the SAME VENDOR under a second provider id: both
|
|
146
|
+
// `openai-gpt4o-mini` and `anthropic-haiku` share their sibling's credential
|
|
147
|
+
// and base_url, and differ only in the default model they pin (see
|
|
148
|
+
// `client/byok-catalog.js`, which notes the two OpenAI ids "share one
|
|
149
|
+
// credential and one model"). Discovery asks a vendor, so both spellings must
|
|
150
|
+
// reach the one list its key can already answer — otherwise a user holding a
|
|
151
|
+
// key filed under the `-haiku` / `-gpt4o-mini` row is told this client has no
|
|
152
|
+
// endpoint for their provider while the identical key under the sibling row
|
|
153
|
+
// is accepted. `custom` and `ollama` are deliberately NOT aliased: the first
|
|
154
|
+
// has no fixed base_url (it is supplied per request), and the second is
|
|
155
|
+
// keyless, so neither has a vendor list this module could ask.
|
|
156
|
+
'openai-gpt4o-mini': 'openai',
|
|
157
|
+
'anthropic-haiku': 'anthropic',
|
|
115
158
|
});
|
|
116
159
|
|
|
117
160
|
/**
|