aegis-desktop 0.7.8 → 0.8.0

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.
@@ -45,10 +45,7 @@ const os = require('node:os');
45
45
 
46
46
  const toolsModule = require('./tools.js');
47
47
  const promptModule = require('./prompt.js');
48
- // The direct-dial policy: which base URLs may reach a provider transport, and
49
- // why a remote one is refused rather than metered. The same module the CLI
50
- // resolves (cli/src/custommodels.js), so both hosts answer with one rule.
51
- const { isLocalEndpoint, remoteRefusal } = require('./endpoints.js');
48
+ const localTransport = require('./local.js');
52
49
  const { ShellSession } = require('./shell.js');
53
50
  const { agentSystemPrompt, agentRoleLabel } = require('./agents.js');
54
51
  // Cooperative working-tree sharing (see each module's header). The lock
@@ -61,6 +58,24 @@ const { agentSystemPrompt, agentRoleLabel } = require('./agents.js');
61
58
  const { beginTurnGuard, recordWrite, blocksDestructive } = require('./turn-guard.js');
62
59
  const { acquireWorktreeLock, releaseWorktreeLock } = require('./worktree-lock.js');
63
60
 
61
+ // `~/.aegiscode/.env` — the shared key file both hosts load at startup, so
62
+ // "put your key in the env file" is the whole setup instruction for either app.
63
+ // Optional by design, and resolved the same two ways this repo resolves every
64
+ // shared module: repo-relative in a checkout AND in the CLI's mirrored vendor
65
+ // tree (cli/vendor/client/env-file.js), then the staged app-dir copy a packaged
66
+ // desktop build carries. Absent in all three, `envFile` stays null and byok
67
+ // reads the encrypted store alone — exactly the behaviour before this file.
68
+ let envFile = null;
69
+ try {
70
+ envFile = require('../../../client/env-file.js');
71
+ } catch {
72
+ try {
73
+ envFile = require('../../vendor/env-file.js');
74
+ } catch {
75
+ envFile = null;
76
+ }
77
+ }
78
+
64
79
  /**
65
80
  * How long a turn waits for the working-tree lock before running anyway.
66
81
  *
@@ -74,9 +89,6 @@ const { acquireWorktreeLock, releaseWorktreeLock } = require('./worktree-lock.js
74
89
  */
75
90
  const WORKTREE_LOCK_WAIT_MS = 1500;
76
91
 
77
- /** Classes whose transport is a user-supplied endpoint + credential. */
78
- const CUSTOM_CLASSES = Object.freeze(['openai-compat', 'anthropic']);
79
-
80
92
  /**
81
93
  * Depth at which the task tool stops being offered. The main chat (depth 0)
82
94
  * and subagents down to depth MAX_SUBAGENT_DEPTH - 1 can all delegate, so
@@ -86,20 +98,31 @@ const CUSTOM_CLASSES = Object.freeze(['openai-compat', 'anthropic']);
86
98
  */
87
99
  const MAX_SUBAGENT_DEPTH = 4;
88
100
 
101
+ // The three shipping classes — kept in lockstep with the CLI's HOST_CLASSES
102
+ // (cli/src/engine.js). 'anthropic'/'openai'/'deepseek' still appear in this
103
+ // file as BYOK *provider* ids: upstream names, never model classes.
104
+ //
105
+ // aegis — pooled relay; the account key pays the pool's margin.
106
+ // byok — relayed with the caller's provider key; AEGIS bills a handling fee.
107
+ // local — a model on hardware the user owns, reached over loopback. Bills
108
+ // nobody because there is no vendor: the compute was already bought.
109
+ // Fenced by local.js `remoteRefusal()` — a remote URL here would be
110
+ // an unpaid turn, which is why that gate exists and fails closed.
89
111
  const CLASSES = [
90
112
  { class: 'aegis', label: 'Aegis Cloud', kind: 'cloud' },
91
- { class: 'ollama', label: 'Ollama (local)', kind: 'local' },
92
- { class: 'openai-compat', label: 'Custom OpenAI-compatible', kind: 'custom' },
93
- { class: 'anthropic', label: 'Anthropic-compatible', kind: 'custom' },
94
113
  { class: 'byok', label: 'Bring your own key', kind: 'cloud' },
114
+ { class: 'local', label: 'Local model', kind: 'local' },
95
115
  ];
96
116
 
97
- /** Local settings namespace for one BYOK provider's key. A `byok:` prefix
98
- * keeps this out of the 'anthropic'/'openai-compat' CUSTOM_CLASSES' own
99
- * namespaces, which are a different feature (a self-hosted/compatible
100
- * endpoint's base URL + key) — same store, deliberately separate rows. */
117
+ /** Local settings namespace for one BYOK provider's key. The `byok:` prefix
118
+ * keeps a provider key (e.g. 'anthropic' the *provider*) in its own row out
119
+ * of every model class's row in the same store — one flat 'byok' row could
120
+ * not hold two providers' keys at once. Named once here because two callers
121
+ * now need to take the provider back OUT of a row name. */
122
+ const BYOK_NAMESPACE_PREFIX = 'byok:';
123
+
101
124
  function byokNamespace(providerId) {
102
- return `byok:${providerId}`;
125
+ return `${BYOK_NAMESPACE_PREFIX}${providerId}`;
103
126
  }
104
127
 
105
128
  /** Split a byok model id ("anthropic:claude-sonnet-5") into its provider and
@@ -113,6 +136,28 @@ function splitByokModel(compound) {
113
136
  return { provider: s.slice(0, i), model: s.slice(i + 1) };
114
137
  }
115
138
 
139
+ /**
140
+ * The BYOK key an environment variable supplies for a provider, if any — from
141
+ * `~/.aegiscode/.env` (loaded into the environment at host startup via
142
+ * `client/env-file.js`) or an explicit shell export. '' when neither has one,
143
+ * which leaves the encrypted store the only source, exactly as before.
144
+ *
145
+ * Read HERE and not inside lib/settings.js on purpose. The store's contract is
146
+ * "what I persisted": its tests assert `configured === false` after a
147
+ * remove(), and a store that silently answered from the environment would break
148
+ * that — and would make a provider look configured to code that never learns
149
+ * where the key came from. The resolution chain (store first, environment
150
+ * second) belongs to the class that spends the key, which is this one.
151
+ */
152
+ function providerKeyFromEnv(provider) {
153
+ if (!envFile || typeof envFile.providerKeyFromEnv !== 'function') return '';
154
+ try {
155
+ return envFile.providerKeyFromEnv(provider).key || '';
156
+ } catch {
157
+ return '';
158
+ }
159
+ }
160
+
116
161
  /**
117
162
  * Mirrors aegiscodex-dev's src/backend.js DEEPSEEK_REASONING_MODEL_RE +
118
163
  * EFFORT_TOKEN_BUDGET verbatim. DeepSeek's reasoning models (deepseek-flash,
@@ -134,12 +179,6 @@ function splitByokModel(compound) {
134
179
  const DEEPSEEK_REASONING_MODEL_RE = /^deepseek-(v4(\.\d+)?-(flash|pro)|flash|pro|reasoner)$/;
135
180
  const EFFORT_TOKEN_BUDGET = { low: 8192, medium: 16384, high: 32768 };
136
181
 
137
- /** The one class whose wire format REQUIRES a stated `max_tokens`: Anthropic's
138
- * Messages API 400s without it, so that field is derived from the effort rung
139
- * rather than invented by the transport (which is what a blanket
140
- * `max_tokens: maxTokens || 4096` did — see providers.anthropicMessages). */
141
- const REQUIRES_STATED_BUDGET = new Set(['anthropic']);
142
-
143
182
  /**
144
183
  * Idle-stream budget for a pooled brain call ("work autonomously"). The
145
184
  * generic watchdog in vendor/aegis.js kills a stream that goes 60s without a
@@ -174,8 +213,7 @@ const AUTONOMOUS_IDLE_TIMEOUT_MS = 15 * 60_000;
174
213
  * so a caller asking for 1024 silently ran on 32768.
175
214
  * 2. with nothing stated, a model that reasons against its own output budget
176
215
  * (DeepSeek bills hidden chain-of-thought against the SAME budget as the
177
- * answer) or a class whose wire format REQUIRES the field (Anthropic's
178
- * Messages API) gets the Effort rung. This is why the renderer's
216
+ * answer) gets the Effort rung. This is why the renderer's
179
217
  * max-tokens dropdown was removed rather than fixed: at its 4k default a
180
218
  * reasoning model spent the entire budget thinking and finished empty —
181
219
  * no error, no tool call, just a "completed" turn with nothing in it.
@@ -187,10 +225,10 @@ const AUTONOMOUS_IDLE_TIMEOUT_MS = 15 * 60_000;
187
225
  * doubled-budget retry plus emptyTurnError — rather than by inflating the
188
226
  * caller's ceiling up front.
189
227
  */
190
- function reasoningBudget(cls, model, maxTokens, effort) {
228
+ function reasoningBudget(model, maxTokens, effort) {
191
229
  const stated = Number(maxTokens);
192
230
  if (Number.isFinite(stated) && stated > 0) return stated;
193
- if (DEEPSEEK_REASONING_MODEL_RE.test(String(model || '')) || REQUIRES_STATED_BUDGET.has(cls)) {
231
+ if (DEEPSEEK_REASONING_MODEL_RE.test(String(model || ''))) {
194
232
  const eff = effort === 'low' || effort === 'medium' ? effort : 'high';
195
233
  return EFFORT_TOKEN_BUDGET[eff];
196
234
  }
@@ -394,7 +432,18 @@ function emptyTurnError({ cls, model, maxTokens, finishReason }) {
394
432
  return err;
395
433
  }
396
434
 
397
- function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBuilder, env, getConfirmMode }) {
435
+ function createLocalEngine({
436
+ aegis,
437
+ settings,
438
+ tools,
439
+ promptBuilder,
440
+ env,
441
+ getConfirmMode,
442
+ // Injectable for tests; defaults to the real transport so every existing
443
+ // caller (desktop/main.js, the CLI's deps.js) wires the local class by
444
+ // simply existing.
445
+ localTransport: localT = localTransport,
446
+ }) {
398
447
  const controllers = new Map(); // sessionId -> AbortController
399
448
  const T = tools || toolsModule;
400
449
  const buildSystemPrompt = (promptBuilder && promptBuilder.buildSystemPrompt) || promptModule.buildSystemPrompt;
@@ -656,54 +705,44 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
656
705
  return guardedExecute(name, args, toolCtx);
657
706
  }
658
707
 
659
- /**
660
- * Custom endpoints are only usable when they are actually configured:
661
- * a base URL is mandatory for both, and Anthropic additionally needs its own
662
- * key (the wire format authenticates with x-api-key). Reporting them as
663
- * always-ready made chat() POST to `${undefined}/v1/…` (defect #2).
664
- *
665
- * A stored REMOTE base URL is reported as not-configured rather than ready,
666
- * plus `blocked` and the reason. This is the third face of the direct-dial
667
- * gate (storage in settings.js set, dispatch in chat below): a row that
668
- * predates the rule must not be OFFERED either, or the class list advertises
669
- * a lane the turn then refuses. The reason travels so the UI can explain it
670
- * instead of showing a dead row — and it is recoverable by design: saving a
671
- * local URL (or clearing the field) makes the class usable again, which is
672
- * what the refusal text tells the user to do.
673
- */
674
- function customStatus(cls) {
675
- const cfg = settings.get(cls) || {};
676
- const baseURL = typeof cfg.baseURL === 'string' ? cfg.baseURL.trim() : '';
677
- const hasBase = Boolean(baseURL);
678
- const hasKey = Boolean(cfg.configured);
679
- const blocked = hasBase && !isLocalEndpoint(baseURL);
680
- return {
681
- configured: !blocked && (cls === 'anthropic' ? hasBase && hasKey : hasBase),
682
- blocked,
683
- ...(blocked ? { blockedReason: remoteRefusal(baseURL, { subject: `the ${cls} endpoint` }) } : {}),
684
- baseURL,
685
- keyMask: cfg.keyMask || null,
686
- };
687
- }
688
-
689
708
  async function listClasses() {
690
- const status = await ollama.probe().catch(() => ({ running: false }));
691
709
  return CLASSES.map((c) => {
692
- if (c.class === 'ollama') return { ...c, configured: Boolean(status.running) };
693
710
  if (c.class === 'aegis') {
694
711
  return { ...c, configured: Boolean(aegis.apiKey) };
695
712
  }
696
713
  if (c.class === 'byok') {
697
- // Configured means "at least one provider has a locally-stored key",
698
- // not a single baseURL+key pair like the CUSTOM_CLASSES below — byok
699
- // holds one row per provider (byokNamespace), so customStatus's shape
700
- // does not apply here.
701
- const anyConfigured = (settings.list() || []).some(
702
- (s) => s && typeof s.provider === 'string' && s.provider.startsWith('byok:') && s.configured
703
- );
714
+ // Configured means "this host could actually spend a key for at least
715
+ // one provider" — byok holds one row per provider (byokNamespace), so
716
+ // there is no single baseURL+key pair to report. A row whose key lives
717
+ // in `~/.aegiscode/.env` counts: that is the whole point of reading the
718
+ // file, and calling it unconfigured would send the user to a Settings
719
+ // row they do not need. A stored key still short-circuits, so an install
720
+ // with no env file pays one lookup per stored row and nothing else.
721
+ //
722
+ // The authoritative per-provider answer is `listModels`, which has the
723
+ // server's catalog and so can name a provider the user has never opened
724
+ // Settings for. This one can only see providers with a stored row.
725
+ const rows = settings.list() || [];
726
+ const anyConfigured = rows.some((s) => {
727
+ if (!s || typeof s.provider !== 'string' || !s.provider.startsWith('byok:')) return false;
728
+ if (s.configured) return true;
729
+ return Boolean(providerKeyFromEnv(s.provider.slice(BYOK_NAMESPACE_PREFIX.length)));
730
+ });
704
731
  return { ...c, configured: anyConfigured };
705
732
  }
706
- return { ...c, ...customStatus(c.class) };
733
+ if (c.class === 'local') {
734
+ // Always "configured": this class needs no credential at all, which is
735
+ // the entire reason it is free. Whether a daemon is actually listening
736
+ // is a different question, asked where the answer belongs — in
737
+ // listModels, next to the model list the user is looking at, so the
738
+ // state reads "start the daemon" instead of "go and configure
739
+ // something", which is what an unconfigured row would say.
740
+ return { ...c, configured: true };
741
+ }
742
+ // Unreachable while CLASSES lists only aegis + byok + local; kept so a
743
+ // future class added without a status rule reads as unconfigured rather
744
+ // than silently appearing ready (the "no saved key" fallback shape).
745
+ return { ...c, configured: false };
707
746
  });
708
747
  }
709
748
 
@@ -723,10 +762,6 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
723
762
  const data = await aegis.listModels();
724
763
  return { class: cls, models: filterAegisCatalog(normalizeCatalog(data && data.models)) };
725
764
  }
726
- if (cls === 'ollama') {
727
- const tags = await ollama.listTags();
728
- return { class: cls, models: tags.map((t) => ({ id: t.id })) };
729
- }
730
765
  if (cls === 'byok') {
731
766
  // The server's catalog names every provider it accepts a key for, the
732
767
  // models each unlocks, and whether an AEGIS account key is even needed
@@ -757,7 +792,13 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
757
792
  const models = [];
758
793
  for (const p of providers) {
759
794
  if (!p || !p.id) continue;
760
- const local = settings.get(byokNamespace(p.id)) || {};
795
+ const rowCfg = settings.get(byokNamespace(p.id)) || {};
796
+ // A key from `~/.aegiscode/.env` makes the provider usable, so it must
797
+ // read as configured here — this is the flag `needsProviderKey` is built
798
+ // on, and the one the renderer's per-provider rows mirror. Reporting it
799
+ // unconfigured while `chat()` happily spends the env key is exactly the
800
+ // "no saved key" contradiction this file removes.
801
+ const fromEnv = providerKeyFromEnv(p.id);
761
802
  const rawModels = Array.isArray(p.models) ? p.models : [];
762
803
  for (const m of rawModels) {
763
804
  const modelId = typeof m === 'string' ? m : m && m.id;
@@ -766,7 +807,7 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
766
807
  id: `${p.id}:${modelId}`,
767
808
  label: `${p.label || p.id} — ${modelId}`,
768
809
  provider: p.id,
769
- configured: Boolean(local.configured),
810
+ configured: Boolean(rowCfg.configured || fromEnv),
770
811
  });
771
812
  }
772
813
  }
@@ -777,27 +818,42 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
777
818
  fee,
778
819
  };
779
820
  }
780
- // Custom endpoints: the model id is the *user's* choice — a provider model
781
- // name, never a URL. Offering the configured base URL as an `id` meant that
782
- // leaving the default selection POSTed `model: "https://api.openai.com/v1"`,
783
- // an upstream 400 invalid-model on every call (defect B). There is nothing
784
- // to enumerate, so the list stays empty and `needsModelId` tells the
785
- // renderer to prompt for a typed id instead. The base URL still travels
786
- // along for display only.
787
- const cfg = settings.get(cls) || {};
788
- const baseURL = typeof cfg.baseURL === 'string' ? cfg.baseURL.trim() : '';
789
- // Same reporting as customStatus: a stored remote URL is not a usable
790
- // model list, and the reason has to reach the UI with it (see customStatus).
791
- const blocked = Boolean(baseURL) && !isLocalEndpoint(baseURL);
792
- return {
793
- class: cls,
794
- models: [],
795
- needsModelId: true,
796
- baseURL,
797
- ...(blocked
798
- ? { blocked: true, blockedReason: remoteRefusal(baseURL, { subject: `the ${cls} endpoint` }) }
799
- : {}),
800
- };
821
+ if (cls === 'local') {
822
+ // No credential to check, so the only question is whether anything is
823
+ // listening. The probe never throws (local.js), so a machine with no
824
+ // daemon reports a STATE and the renderer invites the user to start one —
825
+ // the same shape as the pooled class's `needsKey` above, and for the same
826
+ // reason: an install in its default state must be told what unblocks it,
827
+ // not shown a transport error.
828
+ const cfg = settings.get('local') || {};
829
+ const baseURL = cfg.baseURL || localT.DEFAULT_BASE;
830
+ const status = await localT.probe(baseURL);
831
+ if (!status.running) {
832
+ return { class: cls, models: [], baseURL: status.baseURL, needsDaemon: true };
833
+ }
834
+ // The daemon is up. Its tag list is Ollama's native endpoint, so a
835
+ // non-Ollama server on the same box (llama.cpp, LM Studio, vLLM) answers
836
+ // 404 here — recoverable, and it must not cost the class its usability:
837
+ // the renderer falls back to letting the user type a model name, and the
838
+ // transport itself never needs this call.
839
+ let tags = [];
840
+ let listed = true;
841
+ try {
842
+ tags = await localT.listTags(baseURL);
843
+ } catch {
844
+ listed = false;
845
+ }
846
+ return { class: cls, models: tags, baseURL: status.baseURL, listed };
847
+ }
848
+ // Unreachable by construction: CLASSES lists only 'aegis', 'byok' and
849
+ // 'local', all returned above. Thrown rather than falling through to an
850
+ // empty list, because "no models" is a state the renderer renders as a
851
+ // normal empty picker — an unknown class would look like a
852
+ // configured-but-empty account instead of the bug it is. Same shape as
853
+ // dispatch's tail.
854
+ throw new Error(
855
+ `listModels: unknown model class ${JSON.stringify(cls)} — this build ships 'aegis', 'byok' and 'local' only`
856
+ );
801
857
  }
802
858
 
803
859
  /**
@@ -961,19 +1017,6 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
961
1017
  });
962
1018
  }
963
1019
 
964
- if (cls === 'ollama') {
965
- return ollama.chat({
966
- model: opts.model,
967
- prompt: opts.prompt,
968
- system: opts.system,
969
- messages: opts.messages,
970
- maxTokens: opts.maxTokens,
971
- signal: opts.signal,
972
- onDelta: opts.onDelta,
973
- ...(opts.tools.length ? { tools: opts.tools, toolChoice: opts.toolChoice } : {}),
974
- });
975
- }
976
-
977
1020
  if (cls === 'byok') {
978
1021
  // opts.model is still the compound "provider:model" id here — the
979
1022
  // relay wants them split (a `provider` field plus a bare `model`).
@@ -1021,21 +1064,46 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
1021
1064
  }
1022
1065
  }
1023
1066
 
1024
- const common = {
1025
- baseURL: opts.cfg.baseURL,
1026
- apiKey: opts.apiKey,
1027
- model: opts.model,
1028
- prompt: opts.prompt,
1029
- system: opts.system,
1030
- messages: opts.messages,
1031
- maxTokens: opts.maxTokens,
1032
- signal: opts.signal,
1033
- onDelta: opts.onDelta,
1034
- ...(opts.tools.length ? { tools: opts.tools, toolChoice: opts.toolChoice } : {}),
1035
- };
1067
+ if (cls === 'local') {
1068
+ // The one class whose turn leaves this machine for hardware the user
1069
+ // owns. `baseURL` comes from the 'local' settings row and is re-checked
1070
+ // by the transport on every call (local.js `remoteRefusal`), so a row
1071
+ // hand-edited to a public address after it was saved still cannot be
1072
+ // dialed — the engine is not the only gate, it is the first one.
1073
+ const baseURL = (opts.cfg && opts.cfg.baseURL) || localT.DEFAULT_BASE;
1074
+ const call = (useTools) =>
1075
+ localT.chat({
1076
+ baseURL,
1077
+ model: opts.model,
1078
+ prompt: opts.prompt,
1079
+ system: opts.system,
1080
+ messages: opts.messages,
1081
+ maxTokens: opts.maxTokens,
1082
+ signal: opts.signal,
1083
+ onDelta: opts.onDelta,
1084
+ ...(useTools && opts.tools.length ? { tools: opts.tools, toolChoice: opts.toolChoice } : {}),
1085
+ });
1086
+ try {
1087
+ return await call(true);
1088
+ } catch (e) {
1089
+ // An older daemon 400s on `tools` it does not understand. Sending none
1090
+ // gets the turn answered — without tool access, which is worse than the
1091
+ // full loop and far better than a 400 the user cannot act on. Retried
1092
+ // once, and only for that one status: a 400 about the model name or the
1093
+ // message shape is not fixed by dropping schemas, and retrying it would
1094
+ // send the same broken turn twice.
1095
+ if (opts.tools.length && e && e.status === 400) return call(false);
1096
+ throw e;
1097
+ }
1098
+ }
1036
1099
 
1037
- if (cls === 'anthropic') return providers.anthropicMessages(common);
1038
- return providers.openaiCompatible(common);
1100
+ // Unreachable by construction: CLASSES lists only 'aegis', 'byok' and
1101
+ // 'local', all returned above. Thrown rather than falling through to a
1102
+ // default transport, because a class that silently borrows another's wire
1103
+ // format is this file's recurring failure mode.
1104
+ throw new Error(
1105
+ `dispatch: unknown model class ${JSON.stringify(cls)} — this build ships 'aegis', 'byok' and 'local' only`
1106
+ );
1039
1107
  }
1040
1108
 
1041
1109
  async function chat(payload, onDelta) {
@@ -1048,7 +1116,7 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
1048
1116
  // gets no stated budget, which is exactly the "hidden CoT ate the whole
1049
1117
  // default and returned nothing" failure this budget exists to prevent.
1050
1118
  const reasoningModelId = cls === 'byok' ? splitByokModel(model).model : model;
1051
- const maxTokens = reasoningBudget(cls, reasoningModelId, payload && payload.maxTokens, payload && payload.effort);
1119
+ const maxTokens = reasoningBudget(reasoningModelId, payload && payload.maxTokens, payload && payload.effort);
1052
1120
  // The caller's OWN number, kept apart from `maxTokens` above. That one
1053
1121
  // collapses two different facts into a single value — "the caller stated
1054
1122
  // 4096" and "effort implies 32768" — and the pooled path must treat them
@@ -1104,8 +1172,7 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
1104
1172
  // build a prompt promising tool access the transport silently drops —
1105
1173
  // the model would reason about exec/readFile and never see a result.
1106
1174
  const toolsEnabled = cls !== 'byok' && !(payload && payload.tools === false);
1107
- const wire = cls === 'anthropic' ? 'anthropic' : 'openai';
1108
- const toolSchemas = toolsEnabled ? T.toolsFor(wire, { includeSubagent: depth < MAX_SUBAGENT_DEPTH }) : [];
1175
+ const toolSchemas = toolsEnabled ? T.toolsFor({ includeSubagent: depth < MAX_SUBAGENT_DEPTH }) : [];
1109
1176
  const toolChoice = (payload && payload.toolChoice) || null;
1110
1177
 
1111
1178
  const system = (payload && payload.system) || buildSystemPrompt(envFor(payload));
@@ -1135,14 +1202,22 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
1135
1202
  // under the literal class name — one flat 'byok' row could not hold
1136
1203
  // more than one provider's key at a time.
1137
1204
  const byokParts = cls === 'byok' ? splitByokModel(model) : null;
1205
+ // aegis carries its own credential on the `aegis` client; byok is the
1206
+ // only class whose key comes out of the settings store, and it comes from
1207
+ // the provider named in the model id.
1138
1208
  const cfg =
1139
- cls === 'aegis' || cls === 'ollama' ? {}
1140
- : cls === 'byok' ? settings.get(byokNamespace(byokParts.provider)) || {}
1141
- : settings.get(cls) || {};
1142
- const apiKey =
1143
- cls === 'aegis' || cls === 'ollama' ? null
1144
- : cls === 'byok' ? settings.rawKey(byokNamespace(byokParts.provider))
1145
- : settings.rawKey(cls);
1209
+ cls === 'byok' ? settings.get(byokNamespace(byokParts.provider)) || {}
1210
+ : cls === 'local' ? settings.get('local') || {}
1211
+ : {};
1212
+ // Where a byok key comes from, in order: the encrypted store (a key saved
1213
+ // through the app), then the shared `~/.aegiscode/.env` (client/env-file.js,
1214
+ // read into process.env at start-up). The store wins when it holds one, so
1215
+ // removing a key there still means removed — the file is the convenience,
1216
+ // not a second authority.
1217
+ const storedKey = cls === 'byok' ? settings.rawKey(byokNamespace(byokParts.provider)) : null;
1218
+ const apiKey = cls !== 'byok'
1219
+ ? null
1220
+ : (storedKey || providerKeyFromEnv(byokParts.provider));
1146
1221
 
1147
1222
  if (cls === 'byok' && (!byokParts.provider || !byokParts.model)) {
1148
1223
  const err = new Error(
@@ -1154,7 +1229,8 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
1154
1229
  }
1155
1230
  if (cls === 'byok' && !apiKey) {
1156
1231
  const err = new Error(
1157
- `byok: no key saved for "${byokParts.provider}" — add one before chatting with this model.`
1232
+ `No key for "${byokParts.provider}" yet. Easiest fix: /class aegis (uses your AEGIS key, ` +
1233
+ `no setup) — or add this provider's own key: /byok-key ${byokParts.provider} <key>.`
1158
1234
  );
1159
1235
  err.status = 400;
1160
1236
  throw err;
@@ -1174,58 +1250,28 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
1174
1250
  // key to get), so this refuses only the send, and only until a key lands.
1175
1251
  if (cls === 'byok' && !aegis.apiKey) {
1176
1252
  const err = new Error(
1177
- 'byok: connect your AEGIS account key first — the BYOK handling fee is billed there. ' +
1178
- 'Add it in the Status card (or run /login), then try again.'
1253
+ 'No AEGIS key connected yet. Easiest fix: /class aegis (skips BYOK entirely) — ' +
1254
+ 'or add your AEGIS key with /login, then try again.'
1179
1255
  );
1180
1256
  err.status = 401;
1181
1257
  throw err;
1182
1258
  }
1183
1259
 
1184
- // THE DIRECT-DIAL GATE. A custom class talks to the user's base URL
1185
- // itself (providers.js openaiCompatible / anthropicMessages), and that
1186
- // transport bills NOBODY: no pooled margin, no BYOK handling fee, no
1187
- // account key attached. The only usage it may therefore carry is an
1188
- // endpoint on this machine, where there is no vendor to pay. A remote URL
1189
- // here is an unpaid turn, and it is refused rather than metered because
1190
- // there is nothing to meter it against — the relay accepts a fixed
1191
- // catalog of provider ids (services/nexus_provider/catalog.py), so an
1192
- // arbitrary remote URL cannot be billed there either.
1193
- //
1194
- // Checked at DISPATCH and not only at storage (settings.js set refuses
1195
- // the same URL): a row written before this rule existed, or hand-edited
1196
- // into settings.json, is still in the file, and reading it back happily
1197
- // would keep the lane open. Both seams, one rule — the message is
1198
- // endpoints.js's, so the desktop and the CLI say the same thing.
1199
- if (CUSTOM_CLASSES.includes(cls)) {
1200
- const raw = cfg && typeof cfg.baseURL === 'string' ? cfg.baseURL.trim() : '';
1201
- if (raw && !isLocalEndpoint(raw)) {
1202
- const err = new Error(
1203
- remoteRefusal(raw, {
1204
- subject: `the ${cls} endpoint`,
1205
- hint:
1206
- 'This class dials your URL directly and bills nobody, so it is local-only. ' +
1207
- 'Use a model on this machine, or move the provider to the BYOK card (/class byok), ' +
1208
- 'which relays through AEGIS and charges the handling fee.',
1209
- })
1210
- );
1211
- err.status = 400;
1212
- err.code = 'CUSTOM_ENDPOINT_NOT_LOCAL';
1213
- throw err;
1214
- }
1260
+ // The direct-dial gate, restored for the one class that legitimately
1261
+ // dials. It policed a lane that could reach ANY user-supplied URL, which
1262
+ // billed nobody — no pooled margin, no BYOK handling fee, no account key
1263
+ // attached — so a remote URL there was an unpaid turn to refuse rather
1264
+ // than meter. That open-ended lane is gone for good. `local` is the case
1265
+ // its own reasoning carved out: a model on a machine the user owns, where
1266
+ // there is no vendor to pay in the first place, so there is nothing to
1267
+ // bill and nobody being cheated. Checked here before any byte goes out,
1268
+ // and again inside the transport, because a settings row can be edited on
1269
+ // disk after it was written. Anything not local is refused, not metered —
1270
+ // remote models belong on `aegis` or `byok`, both of which bill.
1271
+ if (cls === 'local') {
1272
+ const refusal = localT.remoteRefusal(cfg.baseURL || localT.DEFAULT_BASE);
1273
+ if (refusal) throw refusal;
1215
1274
  }
1216
-
1217
- // Custom classes carry no enumerable model list (see listModels), so a
1218
- // blank id here means the user never typed one. Fail loudly in-process
1219
- // instead of shipping `model: undefined` upstream (defect B).
1220
- if (CUSTOM_CLASSES.includes(cls) && (typeof model !== 'string' || !model.trim())) {
1221
- const err = new Error(
1222
- `${cls}: a model id is required — type the provider's model name ` +
1223
- '(the base URL is not a model).'
1224
- );
1225
- err.status = 400;
1226
- throw err;
1227
- }
1228
-
1229
1275
  const base = {
1230
1276
  cls, model, mode: payload && payload.mode, maxTokens, statedMaxTokens, autonomous, sessionId, signal, onDelta, cfg, apiKey, toolChoice,
1231
1277
  effort: payload && payload.effort,
@@ -1440,16 +1486,7 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
1440
1486
  // clear it explicitly, being re-dispatches inside round 1).
1441
1487
  const opts = { ...base, system, messages: history, prompt, tools: toolSchemas, recallDeep: base.recallDeep && round === 1 };
1442
1488
  let res;
1443
- try {
1444
- res = await dispatch(cls, opts);
1445
- } catch (e) {
1446
- // Ollama's OpenAI shim rejects `tools` on older builds. Retrying once
1447
- // without them keeps local chat working instead of turning an
1448
- // unadvertised capability into a hard failure.
1449
- const retriable = cls === 'ollama' && toolSchemas.length && e && (e.status === 400 || /tool/i.test(e.message || ''));
1450
- if (!retriable) throw e;
1451
- res = await dispatch(cls, { ...opts, tools: [] });
1452
- }
1489
+ res = await dispatch(cls, opts);
1453
1490
  addUsage(res);
1454
1491
 
1455
1492
  // A cancelled turn is over. Both recoveries below exist for "the model
@@ -1539,7 +1576,7 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
1539
1576
  foldPromptIntoHistory();
1540
1577
 
1541
1578
  // Thread the assistant turn (its tool_calls) and each result back in
1542
- // the shapes both wire formats accept (providers.js normalises them).
1579
+ // the OpenAI shape the relay accepts.
1543
1580
  history.push({
1544
1581
  role: 'assistant',
1545
1582
  content: assistantText(res),