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.
- package/README.md +16 -17
- package/lib/local/engine.js +225 -188
- package/lib/local/local.js +433 -0
- package/lib/local/tools.js +7 -56
- package/lib/settings.js +28 -31
- package/main.js +44 -7
- package/package.json +9 -4
- package/renderer/app.js +211 -266
- package/renderer/budget.js +22 -17
- package/renderer/index.html +8 -3
- package/renderer/usage.js +4 -4
- package/vendor/env-file.js +182 -0
- package/lib/local/endpoints.js +0 -228
- package/lib/local/ollama.js +0 -77
- package/lib/local/providers.js +0 -679
- package/renderer/preset-fill.js +0 -79
package/lib/local/engine.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
98
|
-
* keeps
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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
|
|
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)
|
|
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(
|
|
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 || ''))
|
|
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({
|
|
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 "
|
|
698
|
-
//
|
|
699
|
-
//
|
|
700
|
-
//
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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
|
-
|
|
1038
|
-
|
|
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(
|
|
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
|
|
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 === '
|
|
1140
|
-
: cls === '
|
|
1141
|
-
:
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
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
|
-
`
|
|
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
|
-
'
|
|
1178
|
-
'
|
|
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
|
-
//
|
|
1185
|
-
//
|
|
1186
|
-
//
|
|
1187
|
-
//
|
|
1188
|
-
//
|
|
1189
|
-
//
|
|
1190
|
-
// there is
|
|
1191
|
-
//
|
|
1192
|
-
//
|
|
1193
|
-
//
|
|
1194
|
-
//
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
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
|
-
|
|
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
|
|
1579
|
+
// the OpenAI shape the relay accepts.
|
|
1543
1580
|
history.push({
|
|
1544
1581
|
role: 'assistant',
|
|
1545
1582
|
content: assistantText(res),
|