aegis-desktop 0.7.7 → 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,6 +45,7 @@ const os = require('node:os');
45
45
 
46
46
  const toolsModule = require('./tools.js');
47
47
  const promptModule = require('./prompt.js');
48
+ const localTransport = require('./local.js');
48
49
  const { ShellSession } = require('./shell.js');
49
50
  const { agentSystemPrompt, agentRoleLabel } = require('./agents.js');
50
51
  // Cooperative working-tree sharing (see each module's header). The lock
@@ -57,6 +58,24 @@ const { agentSystemPrompt, agentRoleLabel } = require('./agents.js');
57
58
  const { beginTurnGuard, recordWrite, blocksDestructive } = require('./turn-guard.js');
58
59
  const { acquireWorktreeLock, releaseWorktreeLock } = require('./worktree-lock.js');
59
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
+
60
79
  /**
61
80
  * How long a turn waits for the working-tree lock before running anyway.
62
81
  *
@@ -70,9 +89,6 @@ const { acquireWorktreeLock, releaseWorktreeLock } = require('./worktree-lock.js
70
89
  */
71
90
  const WORKTREE_LOCK_WAIT_MS = 1500;
72
91
 
73
- /** Classes whose transport is a user-supplied endpoint + credential. */
74
- const CUSTOM_CLASSES = Object.freeze(['openai-compat', 'anthropic']);
75
-
76
92
  /**
77
93
  * Depth at which the task tool stops being offered. The main chat (depth 0)
78
94
  * and subagents down to depth MAX_SUBAGENT_DEPTH - 1 can all delegate, so
@@ -82,20 +98,31 @@ const CUSTOM_CLASSES = Object.freeze(['openai-compat', 'anthropic']);
82
98
  */
83
99
  const MAX_SUBAGENT_DEPTH = 4;
84
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.
85
111
  const CLASSES = [
86
112
  { class: 'aegis', label: 'Aegis Cloud', kind: 'cloud' },
87
- { class: 'ollama', label: 'Ollama (local)', kind: 'local' },
88
- { class: 'openai-compat', label: 'Custom OpenAI-compatible', kind: 'custom' },
89
- { class: 'anthropic', label: 'Anthropic-compatible', kind: 'custom' },
90
113
  { class: 'byok', label: 'Bring your own key', kind: 'cloud' },
114
+ { class: 'local', label: 'Local model', kind: 'local' },
91
115
  ];
92
116
 
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. */
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
+
97
124
  function byokNamespace(providerId) {
98
- return `byok:${providerId}`;
125
+ return `${BYOK_NAMESPACE_PREFIX}${providerId}`;
99
126
  }
100
127
 
101
128
  /** Split a byok model id ("anthropic:claude-sonnet-5") into its provider and
@@ -109,6 +136,28 @@ function splitByokModel(compound) {
109
136
  return { provider: s.slice(0, i), model: s.slice(i + 1) };
110
137
  }
111
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
+
112
161
  /**
113
162
  * Mirrors aegiscodex-dev's src/backend.js DEEPSEEK_REASONING_MODEL_RE +
114
163
  * EFFORT_TOKEN_BUDGET verbatim. DeepSeek's reasoning models (deepseek-flash,
@@ -130,12 +179,6 @@ function splitByokModel(compound) {
130
179
  const DEEPSEEK_REASONING_MODEL_RE = /^deepseek-(v4(\.\d+)?-(flash|pro)|flash|pro|reasoner)$/;
131
180
  const EFFORT_TOKEN_BUDGET = { low: 8192, medium: 16384, high: 32768 };
132
181
 
133
- /** The one class whose wire format REQUIRES a stated `max_tokens`: Anthropic's
134
- * Messages API 400s without it, so that field is derived from the effort rung
135
- * rather than invented by the transport (which is what a blanket
136
- * `max_tokens: maxTokens || 4096` did — see providers.anthropicMessages). */
137
- const REQUIRES_STATED_BUDGET = new Set(['anthropic']);
138
-
139
182
  /**
140
183
  * Idle-stream budget for a pooled brain call ("work autonomously"). The
141
184
  * generic watchdog in vendor/aegis.js kills a stream that goes 60s without a
@@ -170,8 +213,7 @@ const AUTONOMOUS_IDLE_TIMEOUT_MS = 15 * 60_000;
170
213
  * so a caller asking for 1024 silently ran on 32768.
171
214
  * 2. with nothing stated, a model that reasons against its own output budget
172
215
  * (DeepSeek bills hidden chain-of-thought against the SAME budget as the
173
- * answer) or a class whose wire format REQUIRES the field (Anthropic's
174
- * Messages API) gets the Effort rung. This is why the renderer's
216
+ * answer) gets the Effort rung. This is why the renderer's
175
217
  * max-tokens dropdown was removed rather than fixed: at its 4k default a
176
218
  * reasoning model spent the entire budget thinking and finished empty —
177
219
  * no error, no tool call, just a "completed" turn with nothing in it.
@@ -183,10 +225,10 @@ const AUTONOMOUS_IDLE_TIMEOUT_MS = 15 * 60_000;
183
225
  * doubled-budget retry plus emptyTurnError — rather than by inflating the
184
226
  * caller's ceiling up front.
185
227
  */
186
- function reasoningBudget(cls, model, maxTokens, effort) {
228
+ function reasoningBudget(model, maxTokens, effort) {
187
229
  const stated = Number(maxTokens);
188
230
  if (Number.isFinite(stated) && stated > 0) return stated;
189
- if (DEEPSEEK_REASONING_MODEL_RE.test(String(model || '')) || REQUIRES_STATED_BUDGET.has(cls)) {
231
+ if (DEEPSEEK_REASONING_MODEL_RE.test(String(model || ''))) {
190
232
  const eff = effort === 'low' || effort === 'medium' ? effort : 'high';
191
233
  return EFFORT_TOKEN_BUDGET[eff];
192
234
  }
@@ -390,7 +432,18 @@ function emptyTurnError({ cls, model, maxTokens, finishReason }) {
390
432
  return err;
391
433
  }
392
434
 
393
- 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
+ }) {
394
447
  const controllers = new Map(); // sessionId -> AbortController
395
448
  const T = tools || toolsModule;
396
449
  const buildSystemPrompt = (promptBuilder && promptBuilder.buildSystemPrompt) || promptModule.buildSystemPrompt;
@@ -652,42 +705,44 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
652
705
  return guardedExecute(name, args, toolCtx);
653
706
  }
654
707
 
655
- /**
656
- * Custom endpoints are only usable when they are actually configured:
657
- * a base URL is mandatory for both, and Anthropic additionally needs its own
658
- * key (the wire format authenticates with x-api-key). Reporting them as
659
- * always-ready made chat() POST to `${undefined}/v1/…` (defect #2).
660
- */
661
- function customStatus(cls) {
662
- const cfg = settings.get(cls) || {};
663
- const baseURL = typeof cfg.baseURL === 'string' ? cfg.baseURL.trim() : '';
664
- const hasBase = Boolean(baseURL);
665
- const hasKey = Boolean(cfg.configured);
666
- return {
667
- configured: cls === 'anthropic' ? hasBase && hasKey : hasBase,
668
- baseURL,
669
- keyMask: cfg.keyMask || null,
670
- };
671
- }
672
-
673
708
  async function listClasses() {
674
- const status = await ollama.probe().catch(() => ({ running: false }));
675
709
  return CLASSES.map((c) => {
676
- if (c.class === 'ollama') return { ...c, configured: Boolean(status.running) };
677
710
  if (c.class === 'aegis') {
678
711
  return { ...c, configured: Boolean(aegis.apiKey) };
679
712
  }
680
713
  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
- );
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
+ });
688
731
  return { ...c, configured: anyConfigured };
689
732
  }
690
- 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 };
691
746
  });
692
747
  }
693
748
 
@@ -707,28 +762,43 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
707
762
  const data = await aegis.listModels();
708
763
  return { class: cls, models: filterAegisCatalog(normalizeCatalog(data && data.models)) };
709
764
  }
710
- if (cls === 'ollama') {
711
- const tags = await ollama.listTags();
712
- return { class: cls, models: tags.map((t) => ({ id: t.id })) };
713
- }
714
765
  if (cls === 'byok') {
715
766
  // The server's catalog names every provider it accepts a key for, the
716
767
  // 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.
768
+ // to ask (it is not — the catalog answers an anonymous request).
769
+ // Deliberately NOT gated on aegis.apiKey the way the pooled class above
770
+ // is: BYOK's whole point is a caller who brings their own credential, and
771
+ // hiding the catalog would hide the answer to "which key do I go and get"
772
+ // from exactly the person deciding whether to bother. Nothing here
773
+ // enforces billing either: the desktop is one client of a route that is
774
+ // unauthenticated by design, so refusing in this process would stop
775
+ // exactly one host out of many. The relay owns that decision, and reports
776
+ // it via `fee.require_balance` below.
721
777
  let providers = [];
778
+ let fee = null;
722
779
  try {
723
780
  const data = await aegis.byokProviders();
724
781
  providers = (data && data.providers) || [];
782
+ // The handling fee AEGIS adds on top of the caller's vendor bill. It is
783
+ // the server's own published rate (services/pricing.price_byok_call) and
784
+ // is passed through untouched — never re-derived here, because a client
785
+ // that hardcodes a fee is a client that can disagree with the ledger.
786
+ // Absent until the server publishes one, and the UI must then say
787
+ // nothing rather than show a guess.
788
+ fee = (data && data.fee) || null;
725
789
  } catch {
726
790
  providers = [];
727
791
  }
728
792
  const models = [];
729
793
  for (const p of providers) {
730
794
  if (!p || !p.id) continue;
731
- 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);
732
802
  const rawModels = Array.isArray(p.models) ? p.models : [];
733
803
  for (const m of rawModels) {
734
804
  const modelId = typeof m === 'string' ? m : m && m.id;
@@ -737,25 +807,53 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
737
807
  id: `${p.id}:${modelId}`,
738
808
  label: `${p.label || p.id} — ${modelId}`,
739
809
  provider: p.id,
740
- configured: Boolean(local.configured),
810
+ configured: Boolean(rowCfg.configured || fromEnv),
741
811
  });
742
812
  }
743
813
  }
744
814
  return {
745
815
  class: cls, models, providers,
746
816
  needsProviderKey: models.length > 0 && !models.some((m) => m.configured),
817
+ needsAegisKey: !aegis.apiKey,
818
+ fee,
747
819
  };
748
820
  }
749
- // Custom endpoints: the model id is the *user's* choice — a provider model
750
- // name, never a URL. Offering the configured base URL as an `id` meant that
751
- // leaving the default selection POSTed `model: "https://api.openai.com/v1"`,
752
- // an upstream 400 invalid-model on every call (defect B). There is nothing
753
- // to enumerate, so the list stays empty and `needsModelId` tells the
754
- // renderer to prompt for a typed id instead. The base URL still travels
755
- // along for display only.
756
- const cfg = settings.get(cls) || {};
757
- const baseURL = typeof cfg.baseURL === 'string' ? cfg.baseURL.trim() : '';
758
- return { class: cls, models: [], needsModelId: true, baseURL };
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
+ );
759
857
  }
760
858
 
761
859
  /**
@@ -919,19 +1017,6 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
919
1017
  });
920
1018
  }
921
1019
 
922
- if (cls === 'ollama') {
923
- return ollama.chat({
924
- model: opts.model,
925
- prompt: opts.prompt,
926
- system: opts.system,
927
- messages: opts.messages,
928
- maxTokens: opts.maxTokens,
929
- signal: opts.signal,
930
- onDelta: opts.onDelta,
931
- ...(opts.tools.length ? { tools: opts.tools, toolChoice: opts.toolChoice } : {}),
932
- });
933
- }
934
-
935
1020
  if (cls === 'byok') {
936
1021
  // opts.model is still the compound "provider:model" id here — the
937
1022
  // relay wants them split (a `provider` field plus a bare `model`).
@@ -941,35 +1026,84 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
941
1026
  // automatically by byokChatCompletion() as X-AEGIS-Key so the account
942
1027
  // gets billed the handling fee; see client/aegis.js.
943
1028
  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
- });
1029
+ try {
1030
+ return await aegis.byokChatCompletion({
1031
+ provider,
1032
+ model: bareModel,
1033
+ providerKey: opts.apiKey,
1034
+ prompt: opts.prompt,
1035
+ system: opts.system,
1036
+ messages: opts.messages,
1037
+ maxTokens: opts.maxTokens,
1038
+ stream: true,
1039
+ onStream: opts.onDelta,
1040
+ signal: opts.signal,
1041
+ });
1042
+ } catch (e) {
1043
+ // The relay's own balance gate (aegis1 `AEGIS_BYOK_REQUIRE_BALANCE`,
1044
+ // app.py `byok_chat_completions`) answers 402 with a body aimed at an
1045
+ // API consumer — "Insufficient balance. Top up to continue using
1046
+ // BYOK." Shown raw in a chat transcript that reads as a crash rather
1047
+ // than a bill, so the one thing the user has to DO is said here, in the
1048
+ // hosts' own voice. The status is preserved so every existing caller
1049
+ // (the CLI's error painter, the desktop turn guard) still sees a 402.
1050
+ //
1051
+ // The provider key is untouched by this: it is the caller's own and it
1052
+ // is still valid. What ran out is the AEGIS balance the handling fee
1053
+ // is billed against, which is the whole reason this lane has a fee.
1054
+ if (e && e.status === 402) {
1055
+ const err = new Error(
1056
+ 'byok: this account has no AEGIS balance left, and the BYOK handling fee is ' +
1057
+ 'billed there — your provider key is still valid, this is not a key problem. ' +
1058
+ 'Top up the account, then try again (or /class aegis to use the pooled lane).'
1059
+ );
1060
+ err.status = 402;
1061
+ throw err;
1062
+ }
1063
+ throw e;
1064
+ }
956
1065
  }
957
1066
 
958
- const common = {
959
- baseURL: opts.cfg.baseURL,
960
- apiKey: opts.apiKey,
961
- model: opts.model,
962
- prompt: opts.prompt,
963
- system: opts.system,
964
- messages: opts.messages,
965
- maxTokens: opts.maxTokens,
966
- signal: opts.signal,
967
- onDelta: opts.onDelta,
968
- ...(opts.tools.length ? { tools: opts.tools, toolChoice: opts.toolChoice } : {}),
969
- };
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
+ }
970
1099
 
971
- if (cls === 'anthropic') return providers.anthropicMessages(common);
972
- 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
+ );
973
1107
  }
974
1108
 
975
1109
  async function chat(payload, onDelta) {
@@ -982,7 +1116,7 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
982
1116
  // gets no stated budget, which is exactly the "hidden CoT ate the whole
983
1117
  // default and returned nothing" failure this budget exists to prevent.
984
1118
  const reasoningModelId = cls === 'byok' ? splitByokModel(model).model : model;
985
- const maxTokens = reasoningBudget(cls, reasoningModelId, payload && payload.maxTokens, payload && payload.effort);
1119
+ const maxTokens = reasoningBudget(reasoningModelId, payload && payload.maxTokens, payload && payload.effort);
986
1120
  // The caller's OWN number, kept apart from `maxTokens` above. That one
987
1121
  // collapses two different facts into a single value — "the caller stated
988
1122
  // 4096" and "effort implies 32768" — and the pooled path must treat them
@@ -1038,8 +1172,7 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
1038
1172
  // build a prompt promising tool access the transport silently drops —
1039
1173
  // the model would reason about exec/readFile and never see a result.
1040
1174
  const toolsEnabled = cls !== 'byok' && !(payload && payload.tools === false);
1041
- const wire = cls === 'anthropic' ? 'anthropic' : 'openai';
1042
- const toolSchemas = toolsEnabled ? T.toolsFor(wire, { includeSubagent: depth < MAX_SUBAGENT_DEPTH }) : [];
1175
+ const toolSchemas = toolsEnabled ? T.toolsFor({ includeSubagent: depth < MAX_SUBAGENT_DEPTH }) : [];
1043
1176
  const toolChoice = (payload && payload.toolChoice) || null;
1044
1177
 
1045
1178
  const system = (payload && payload.system) || buildSystemPrompt(envFor(payload));
@@ -1069,14 +1202,22 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
1069
1202
  // under the literal class name — one flat 'byok' row could not hold
1070
1203
  // more than one provider's key at a time.
1071
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.
1072
1208
  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);
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));
1080
1221
 
1081
1222
  if (cls === 'byok' && (!byokParts.provider || !byokParts.model)) {
1082
1223
  const err = new Error(
@@ -1088,24 +1229,49 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
1088
1229
  }
1089
1230
  if (cls === 'byok' && !apiKey) {
1090
1231
  const err = new Error(
1091
- `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>.`
1092
1234
  );
1093
1235
  err.status = 400;
1094
1236
  throw err;
1095
1237
  }
1096
-
1097
- // Custom classes carry no enumerable model list (see listModels), so a
1098
- // blank id here means the user never typed one. Fail loudly in-process
1099
- // instead of shipping `model: undefined` upstream (defect B).
1100
- if (CUSTOM_CLASSES.includes(cls) && (typeof model !== 'string' || !model.trim())) {
1238
+ // …and the AEGIS account key is what makes the turn BILLABLE at all. The
1239
+ // relay authenticates on the provider key and resolves the payer
1240
+ // separately, from X-AEGIS-Key (app.py `_byok_identify_user`): with no
1241
+ // account key the server can attribute the handling fee to no one, so it
1242
+ // is logged against user 0 as uncollected — an anonymous free ride the
1243
+ // fee exists to close. Require the account key here so EVERY BYOK turn is
1244
+ // attributed and every caller pays the handling fee: a funded balance is
1245
+ // debited immediately, an unfunded one records the fee as owed
1246
+ // (token_bank.charge_byok clamps to zero and never refuses), and nobody
1247
+ // is served free. Balance is the server's own concern, not this gate's —
1248
+ // with a balance or without one, the account still pays. `listModels`
1249
+ // still answers anonymously (the catalog is how a user finds out which
1250
+ // key to get), so this refuses only the send, and only until a key lands.
1251
+ if (cls === 'byok' && !aegis.apiKey) {
1101
1252
  const err = new Error(
1102
- `${cls}: a model id is required — type the provider's model name ` +
1103
- '(the base URL is not a model).'
1253
+ 'No AEGIS key connected yet. Easiest fix: /class aegis (skips BYOK entirely) — ' +
1254
+ 'or add your AEGIS key with /login, then try again.'
1104
1255
  );
1105
- err.status = 400;
1256
+ err.status = 401;
1106
1257
  throw err;
1107
1258
  }
1108
1259
 
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;
1274
+ }
1109
1275
  const base = {
1110
1276
  cls, model, mode: payload && payload.mode, maxTokens, statedMaxTokens, autonomous, sessionId, signal, onDelta, cfg, apiKey, toolChoice,
1111
1277
  effort: payload && payload.effort,
@@ -1320,16 +1486,7 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
1320
1486
  // clear it explicitly, being re-dispatches inside round 1).
1321
1487
  const opts = { ...base, system, messages: history, prompt, tools: toolSchemas, recallDeep: base.recallDeep && round === 1 };
1322
1488
  let res;
1323
- try {
1324
- res = await dispatch(cls, opts);
1325
- } catch (e) {
1326
- // Ollama's OpenAI shim rejects `tools` on older builds. Retrying once
1327
- // without them keeps local chat working instead of turning an
1328
- // unadvertised capability into a hard failure.
1329
- const retriable = cls === 'ollama' && toolSchemas.length && e && (e.status === 400 || /tool/i.test(e.message || ''));
1330
- if (!retriable) throw e;
1331
- res = await dispatch(cls, { ...opts, tools: [] });
1332
- }
1489
+ res = await dispatch(cls, opts);
1333
1490
  addUsage(res);
1334
1491
 
1335
1492
  // A cancelled turn is over. Both recoveries below exist for "the model
@@ -1419,7 +1576,7 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
1419
1576
  foldPromptIntoHistory();
1420
1577
 
1421
1578
  // Thread the assistant turn (its tool_calls) and each result back in
1422
- // the shapes both wire formats accept (providers.js normalises them).
1579
+ // the OpenAI shape the relay accepts.
1423
1580
  history.push({
1424
1581
  role: 'assistant',
1425
1582
  content: assistantText(res),