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/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
- ? `Update ${version}available — run: ${state.command}`
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
@@ -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
- err.status = res.status;
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
- const err = new Error(sseError);
1688
- err.status = res.status;
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
 
@@ -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
- * @param {{driveToken: () => Promise<{connected:boolean, email?:string}>}} aegis
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: Boolean(data && data.connected),
73
+ connected,
74
+ googleLinked: connected || raw === true,
75
+ googleLinkedKnown: known || connected,
51
76
  email: (data && data.email) || null,
52
77
  };
53
78
  }
@@ -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 the terminal.`
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
  /**