aegis-desktop 0.7.6 → 0.7.7

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.
@@ -87,8 +87,28 @@ const CLASSES = [
87
87
  { class: 'ollama', label: 'Ollama (local)', kind: 'local' },
88
88
  { class: 'openai-compat', label: 'Custom OpenAI-compatible', kind: 'custom' },
89
89
  { class: 'anthropic', label: 'Anthropic-compatible', kind: 'custom' },
90
+ { class: 'byok', label: 'Bring your own key', kind: 'cloud' },
90
91
  ];
91
92
 
93
+ /** Local settings namespace for one BYOK provider's key. A `byok:` prefix
94
+ * keeps this out of the 'anthropic'/'openai-compat' CUSTOM_CLASSES' own
95
+ * namespaces, which are a different feature (a self-hosted/compatible
96
+ * endpoint's base URL + key) — same store, deliberately separate rows. */
97
+ function byokNamespace(providerId) {
98
+ return `byok:${providerId}`;
99
+ }
100
+
101
+ /** Split a byok model id ("anthropic:claude-sonnet-5") into its provider and
102
+ * bare model parts. `modelId` may itself contain ':' (none of today's
103
+ * catalog ids do, but nothing guarantees that), so only the FIRST segment is
104
+ * the provider — the rest re-joins as the model. */
105
+ function splitByokModel(compound) {
106
+ const s = typeof compound === 'string' ? compound : '';
107
+ const i = s.indexOf(':');
108
+ if (i < 0) return { provider: '', model: s };
109
+ return { provider: s.slice(0, i), model: s.slice(i + 1) };
110
+ }
111
+
92
112
  /**
93
113
  * Mirrors aegiscodex-dev's src/backend.js DEEPSEEK_REASONING_MODEL_RE +
94
114
  * EFFORT_TOKEN_BUDGET verbatim. DeepSeek's reasoning models (deepseek-flash,
@@ -657,6 +677,16 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
657
677
  if (c.class === 'aegis') {
658
678
  return { ...c, configured: Boolean(aegis.apiKey) };
659
679
  }
680
+ if (c.class === 'byok') {
681
+ // Configured means "at least one provider has a locally-stored key",
682
+ // not a single baseURL+key pair like the CUSTOM_CLASSES below — byok
683
+ // holds one row per provider (byokNamespace), so customStatus's shape
684
+ // does not apply here.
685
+ const anyConfigured = (settings.list() || []).some(
686
+ (s) => s && typeof s.provider === 'string' && s.provider.startsWith('byok:') && s.configured
687
+ );
688
+ return { ...c, configured: anyConfigured };
689
+ }
660
690
  return { ...c, ...customStatus(c.class) };
661
691
  });
662
692
  }
@@ -681,6 +711,41 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
681
711
  const tags = await ollama.listTags();
682
712
  return { class: cls, models: tags.map((t) => ({ id: t.id })) };
683
713
  }
714
+ if (cls === 'byok') {
715
+ // The server's catalog names every provider it accepts a key for, the
716
+ // models each unlocks, and whether an AEGIS account key is even needed
717
+ // to ask (it is not — see byokProviders' own docstring). Deliberately
718
+ // NOT gated on aegis.apiKey the way the pooled class above is: BYOK's
719
+ // whole point is a caller who brings their own credential, and the
720
+ // catalog itself answers to an anonymous request.
721
+ let providers = [];
722
+ try {
723
+ const data = await aegis.byokProviders();
724
+ providers = (data && data.providers) || [];
725
+ } catch {
726
+ providers = [];
727
+ }
728
+ const models = [];
729
+ for (const p of providers) {
730
+ if (!p || !p.id) continue;
731
+ const local = settings.get(byokNamespace(p.id)) || {};
732
+ const rawModels = Array.isArray(p.models) ? p.models : [];
733
+ for (const m of rawModels) {
734
+ const modelId = typeof m === 'string' ? m : m && m.id;
735
+ if (!modelId) continue;
736
+ models.push({
737
+ id: `${p.id}:${modelId}`,
738
+ label: `${p.label || p.id} — ${modelId}`,
739
+ provider: p.id,
740
+ configured: Boolean(local.configured),
741
+ });
742
+ }
743
+ }
744
+ return {
745
+ class: cls, models, providers,
746
+ needsProviderKey: models.length > 0 && !models.some((m) => m.configured),
747
+ };
748
+ }
684
749
  // Custom endpoints: the model id is the *user's* choice — a provider model
685
750
  // name, never a URL. Offering the configured base URL as an `id` meant that
686
751
  // leaving the default selection POSTed `model: "https://api.openai.com/v1"`,
@@ -867,6 +932,29 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
867
932
  });
868
933
  }
869
934
 
935
+ if (cls === 'byok') {
936
+ // opts.model is still the compound "provider:model" id here — the
937
+ // relay wants them split (a `provider` field plus a bare `model`).
938
+ // opts.apiKey is the PROVIDER key resolved by chat() above (from the
939
+ // byokNamespace(provider) settings row), never the account's own AEGIS
940
+ // key — that one is baked into the `aegis` client already and attached
941
+ // automatically by byokChatCompletion() as X-AEGIS-Key so the account
942
+ // gets billed the handling fee; see client/aegis.js.
943
+ const { provider, model: bareModel } = splitByokModel(opts.model);
944
+ return aegis.byokChatCompletion({
945
+ provider,
946
+ model: bareModel,
947
+ providerKey: opts.apiKey,
948
+ prompt: opts.prompt,
949
+ system: opts.system,
950
+ messages: opts.messages,
951
+ maxTokens: opts.maxTokens,
952
+ stream: true,
953
+ onStream: opts.onDelta,
954
+ signal: opts.signal,
955
+ });
956
+ }
957
+
870
958
  const common = {
871
959
  baseURL: opts.cfg.baseURL,
872
960
  apiKey: opts.apiKey,
@@ -887,7 +975,14 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
887
975
  async function chat(payload, onDelta) {
888
976
  const cls = payload && payload.class;
889
977
  const model = payload && payload.model;
890
- const maxTokens = reasoningBudget(cls, model, payload && payload.maxTokens, payload && payload.effort);
978
+ // byok's model id is "provider:model" (see splitByokModel) — strip the
979
+ // provider prefix before pattern-matching, or a DeepSeek reasoning model
980
+ // selected under byok ("byok" model "deepseek:deepseek-v4-flash") never
981
+ // matches DEEPSEEK_REASONING_MODEL_RE's anchored pattern and silently
982
+ // gets no stated budget, which is exactly the "hidden CoT ate the whole
983
+ // default and returned nothing" failure this budget exists to prevent.
984
+ const reasoningModelId = cls === 'byok' ? splitByokModel(model).model : model;
985
+ const maxTokens = reasoningBudget(cls, reasoningModelId, payload && payload.maxTokens, payload && payload.effort);
891
986
  // The caller's OWN number, kept apart from `maxTokens` above. That one
892
987
  // collapses two different facts into a single value — "the caller stated
893
988
  // 4096" and "effort implies 32768" — and the pooled path must treat them
@@ -937,8 +1032,12 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
937
1032
  const signal = controller.signal;
938
1033
 
939
1034
  // A caller can opt out of the agent loop entirely (`tools: false`) and get
940
- // the old single-shot turn back.
941
- const toolsEnabled = !(payload && payload.tools === false);
1035
+ // the old single-shot turn back. byok is ALWAYS single-shot: the
1036
+ // stateless relay (aegis.byokChatCompletion, /api/v1/byok/chat/completions)
1037
+ // has no tools/tool_choice parameter at all, so sending schemas here would
1038
+ // build a prompt promising tool access the transport silently drops —
1039
+ // the model would reason about exec/readFile and never see a result.
1040
+ const toolsEnabled = cls !== 'byok' && !(payload && payload.tools === false);
942
1041
  const wire = cls === 'anthropic' ? 'anthropic' : 'openai';
943
1042
  const toolSchemas = toolsEnabled ? T.toolsFor(wire, { includeSubagent: depth < MAX_SUBAGENT_DEPTH }) : [];
944
1043
  const toolChoice = (payload && payload.toolChoice) || null;
@@ -965,8 +1064,35 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
965
1064
  const turnCwd = envFor(payload).cwd;
966
1065
 
967
1066
  try {
968
- const cfg = cls === 'aegis' || cls === 'ollama' ? {} : settings.get(cls) || {};
969
- const apiKey = cls === 'aegis' || cls === 'ollama' ? null : settings.rawKey(cls);
1067
+ // byok's settings row lives under the provider named IN THE MODEL id
1068
+ // ("anthropic:claude-sonnet-5" -> byokNamespace('anthropic')), never
1069
+ // under the literal class name — one flat 'byok' row could not hold
1070
+ // more than one provider's key at a time.
1071
+ const byokParts = cls === 'byok' ? splitByokModel(model) : null;
1072
+ const cfg =
1073
+ cls === 'aegis' || cls === 'ollama' ? {}
1074
+ : cls === 'byok' ? settings.get(byokNamespace(byokParts.provider)) || {}
1075
+ : settings.get(cls) || {};
1076
+ const apiKey =
1077
+ cls === 'aegis' || cls === 'ollama' ? null
1078
+ : cls === 'byok' ? settings.rawKey(byokNamespace(byokParts.provider))
1079
+ : settings.rawKey(cls);
1080
+
1081
+ if (cls === 'byok' && (!byokParts.provider || !byokParts.model)) {
1082
+ const err = new Error(
1083
+ "byok: model must be \"<provider>:<model>\" (pick one from /models) — got " +
1084
+ JSON.stringify(model || '')
1085
+ );
1086
+ err.status = 400;
1087
+ throw err;
1088
+ }
1089
+ if (cls === 'byok' && !apiKey) {
1090
+ const err = new Error(
1091
+ `byok: no key saved for "${byokParts.provider}" — add one before chatting with this model.`
1092
+ );
1093
+ err.status = 400;
1094
+ throw err;
1095
+ }
970
1096
 
971
1097
  // Custom classes carry no enumerable model list (see listModels), so a
972
1098
  // blank id here means the user never typed one. Fail loudly in-process
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "0.7.6",
4
+ "version": "0.7.7",
5
5
  "description": "Thin Electron host for AEGIS — a local chat UI over the shared client/aegis.js transport. Ships transport + UI only; engine logic stays server-side.",
6
6
  "author": {
7
7
  "name": "AEGIS Code",
package/renderer/app.js CHANGED
@@ -2658,6 +2658,12 @@ async function loadModels(cls) {
2658
2658
  hint = null; // carries a link, built below
2659
2659
  } else if (!list.length) {
2660
2660
  hint = cls === 'ollama' ? 'Ollama not running or no models pulled.' : 'No models listed.';
2661
+ } else if (cls === 'byok' && data && data.needsProviderKey) {
2662
+ // Unlike the pooled 'aegis' class, byok still shows every model here —
2663
+ // the catalog answers with no key at all — but none of them are
2664
+ // usable until a provider key is saved in Provider settings below.
2665
+ hint = `${list.length} model${list.length === 1 ? '' : 's'} available — ` +
2666
+ 'add a provider key in Provider settings below to use one.';
2661
2667
  } else {
2662
2668
  hint = `${list.length} model${list.length === 1 ? '' : 's'} available.`;
2663
2669
  }
@@ -2718,6 +2724,71 @@ function applyCustomPreset(cls, modelId) {
2718
2724
 
2719
2725
  // -------------------------------------------------------------- settings pane
2720
2726
 
2727
+ /**
2728
+ * One provider-settings row: name, an optional base-URL field, a key input,
2729
+ * a status label and Save/Remove buttons wired to the generic
2730
+ * `models.settings.*` surface. Shared by the two custom endpoints (which
2731
+ * need a base URL) and the byok providers (which do not — the server
2732
+ * dictates the endpoint; only the key is theirs to set).
2733
+ */
2734
+ function buildSettingRow({ provider, name, cfg, showBaseURL, onSave, onRemove }) {
2735
+ const row = document.createElement('div');
2736
+ row.className = 'setting-row';
2737
+ // Targeted by applyCustomPreset() so picking a Model-card preset can
2738
+ // quick-fill the matching base URL here without a full loadSettings()
2739
+ // round trip.
2740
+ row.dataset.provider = provider;
2741
+
2742
+ const label = document.createElement('div');
2743
+ label.className = 'setting-name';
2744
+ label.textContent = name;
2745
+ row.appendChild(label);
2746
+
2747
+ let baseInput = null;
2748
+ if (showBaseURL) {
2749
+ baseInput = document.createElement('input');
2750
+ baseInput.type = 'text';
2751
+ baseInput.className = 'setting-input setting-base';
2752
+ baseInput.placeholder = 'base URL';
2753
+ baseInput.value = cfg.baseURL || '';
2754
+ row.appendChild(baseInput);
2755
+ }
2756
+
2757
+ const keyInput = document.createElement('input');
2758
+ keyInput.type = 'password';
2759
+ keyInput.className = 'setting-input';
2760
+ keyInput.placeholder = cfg.configured
2761
+ ? `key ${cfg.keyMask} (blank = keep)`
2762
+ : 'API key';
2763
+ row.appendChild(keyInput);
2764
+
2765
+ const status = document.createElement('div');
2766
+ status.className = 'setting-status';
2767
+ status.textContent = cfg.configured ? `configured (${cfg.keyMask})` : 'no key';
2768
+ row.appendChild(status);
2769
+
2770
+ const actions = document.createElement('div');
2771
+ actions.className = 'setting-actions';
2772
+
2773
+ const saveBtn = document.createElement('button');
2774
+ saveBtn.type = 'button';
2775
+ saveBtn.className = 'ghost-btn';
2776
+ saveBtn.textContent = 'Save';
2777
+ saveBtn.addEventListener('click', () => onSave(baseInput ? baseInput.value.trim() : '', keyInput.value));
2778
+ actions.appendChild(saveBtn);
2779
+
2780
+ const removeBtn = document.createElement('button');
2781
+ removeBtn.type = 'button';
2782
+ removeBtn.className = 'ghost-btn danger';
2783
+ removeBtn.textContent = 'Remove';
2784
+ removeBtn.disabled = !cfg.configured;
2785
+ removeBtn.addEventListener('click', onRemove);
2786
+ actions.appendChild(removeBtn);
2787
+
2788
+ row.appendChild(actions);
2789
+ return row;
2790
+ }
2791
+
2721
2792
  async function loadSettings() {
2722
2793
  els.settingsList.innerHTML = '';
2723
2794
  let settings = [];
@@ -2742,61 +2813,38 @@ async function loadSettings() {
2742
2813
  configured: false,
2743
2814
  keyMask: null,
2744
2815
  };
2745
-
2746
- const row = document.createElement('div');
2747
- row.className = 'setting-row';
2748
- // Targeted by applyCustomPreset() so picking a Model-card preset can
2749
- // quick-fill the matching base URL here without a full loadSettings()
2750
- // round trip.
2751
- row.dataset.provider = provider;
2752
-
2753
- const label = document.createElement('div');
2754
- label.className = 'setting-name';
2755
- label.textContent = name;
2756
- row.appendChild(label);
2757
-
2758
- const baseInput = document.createElement('input');
2759
- baseInput.type = 'text';
2760
- baseInput.className = 'setting-input setting-base';
2761
- baseInput.placeholder = 'base URL';
2762
- baseInput.value = cfg.baseURL || '';
2763
- row.appendChild(baseInput);
2764
-
2765
- const keyInput = document.createElement('input');
2766
- keyInput.type = 'password';
2767
- keyInput.className = 'setting-input';
2768
- keyInput.placeholder = cfg.configured
2769
- ? `key ${cfg.keyMask} (blank = keep)`
2770
- : 'API key';
2771
- row.appendChild(keyInput);
2772
-
2773
- const status = document.createElement('div');
2774
- status.className = 'setting-status';
2775
- status.textContent = cfg.configured ? `configured (${cfg.keyMask})` : 'no key';
2776
- row.appendChild(status);
2777
-
2778
- const actions = document.createElement('div');
2779
- actions.className = 'setting-actions';
2780
-
2781
- const saveBtn = document.createElement('button');
2782
- saveBtn.type = 'button';
2783
- saveBtn.className = 'ghost-btn';
2784
- saveBtn.textContent = 'Save';
2785
- saveBtn.addEventListener('click', () =>
2786
- saveSetting(provider, baseInput.value.trim(), keyInput.value)
2787
- );
2788
- actions.appendChild(saveBtn);
2789
-
2790
- const removeBtn = document.createElement('button');
2791
- removeBtn.type = 'button';
2792
- removeBtn.className = 'ghost-btn danger';
2793
- removeBtn.textContent = 'Remove';
2794
- removeBtn.disabled = !cfg.configured;
2795
- removeBtn.addEventListener('click', () => removeSetting(provider));
2796
- actions.appendChild(removeBtn);
2797
-
2798
- row.appendChild(actions);
2799
- els.settingsList.appendChild(row);
2816
+ els.settingsList.appendChild(buildSettingRow({
2817
+ provider, name, cfg, showBaseURL: true,
2818
+ onSave: (baseURL, key) => saveSetting(provider, baseURL, key),
2819
+ onRemove: () => removeSetting(provider),
2820
+ }));
2821
+ }
2822
+
2823
+ // byok: one row per provider the server's catalog names (GET
2824
+ // /api/v1/byok/providers via the engine's listModels('byok')), not a fixed
2825
+ // pair like the two custom endpoints above — the catalog is the source of
2826
+ // truth so a provider added server-side shows up here with no client
2827
+ // release. No base-URL field: byok always talks to AEGIS's own relay
2828
+ // (/api/v1/byok/chat/completions), which is what attaches the AEGIS key
2829
+ // and makes the call billable — the provider key typed here authenticates
2830
+ // to the UPSTREAM provider only.
2831
+ try {
2832
+ const byokData = await models.listModels('byok');
2833
+ const byokProviders = Array.isArray(byokData && byokData.providers) ? byokData.providers : [];
2834
+ for (const p of byokProviders) {
2835
+ if (!p || !p.id) continue;
2836
+ const provider = `byok:${p.id}`;
2837
+ const local = settings.find((s) => s.provider === provider) || {
2838
+ provider, baseURL: '', configured: false, keyMask: null,
2839
+ };
2840
+ els.settingsList.appendChild(buildSettingRow({
2841
+ provider, name: `BYOK: ${p.label || p.id}`, cfg: local, showBaseURL: false,
2842
+ onSave: (_baseURL, key) => saveSetting(provider, '', key),
2843
+ onRemove: () => removeSetting(provider),
2844
+ }));
2845
+ }
2846
+ } catch {
2847
+ /* catalog unreachable (offline, server down) — the two custom rows above still work */
2800
2848
  }
2801
2849
  }
2802
2850