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.
- package/README.md +103 -18
- package/lib/local/engine.js +296 -139
- package/lib/local/local.js +433 -0
- package/lib/local/tools.js +7 -56
- package/lib/settings.js +39 -1
- package/main.js +44 -7
- package/package.json +8 -2
- package/renderer/app.js +307 -246
- package/renderer/budget.js +22 -17
- package/renderer/index.html +8 -2
- package/renderer/stream-policy.js +65 -1
- package/renderer/transcript-view.js +5 -0
- package/renderer/usage.js +46 -15
- package/vendor/aegis.js +21 -3
- package/vendor/env-file.js +182 -0
- package/lib/local/ollama.js +0 -77
- package/lib/local/providers.js +0 -679
package/lib/local/engine.js
CHANGED
|
@@ -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.
|
|
94
|
-
* keeps
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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
|
|
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)
|
|
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(
|
|
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 || ''))
|
|
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({
|
|
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 "
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
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 —
|
|
718
|
-
// NOT gated on aegis.apiKey the way the pooled class above
|
|
719
|
-
// whole point is a caller who brings their own credential, and
|
|
720
|
-
// catalog
|
|
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
|
|
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(
|
|
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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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
|
-
|
|
972
|
-
|
|
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(
|
|
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
|
|
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 === '
|
|
1074
|
-
: cls === '
|
|
1075
|
-
:
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
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
|
-
`
|
|
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
|
-
//
|
|
1098
|
-
//
|
|
1099
|
-
//
|
|
1100
|
-
|
|
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
|
-
|
|
1103
|
-
'
|
|
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 =
|
|
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
|
-
|
|
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
|
|
1579
|
+
// the OpenAI shape the relay accepts.
|
|
1423
1580
|
history.push({
|
|
1424
1581
|
role: 'assistant',
|
|
1425
1582
|
content: assistantText(res),
|