aegis-desktop 0.7.5 → 0.7.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/local/engine.js +131 -5
- package/lib/sync/sessions.js +1 -1
- package/main.js +1 -1
- package/package.json +1 -1
- package/renderer/app.js +131 -60
- package/renderer/usage.js +37 -18
- package/vendor/aegis.js +24 -1
- package/vendor/credentials.js +4 -4
package/lib/local/engine.js
CHANGED
|
@@ -87,8 +87,28 @@ const CLASSES = [
|
|
|
87
87
|
{ class: 'ollama', label: 'Ollama (local)', kind: 'local' },
|
|
88
88
|
{ class: 'openai-compat', label: 'Custom OpenAI-compatible', kind: 'custom' },
|
|
89
89
|
{ class: 'anthropic', label: 'Anthropic-compatible', kind: 'custom' },
|
|
90
|
+
{ class: 'byok', label: 'Bring your own key', kind: 'cloud' },
|
|
90
91
|
];
|
|
91
92
|
|
|
93
|
+
/** Local settings namespace for one BYOK provider's key. A `byok:` prefix
|
|
94
|
+
* keeps this out of the 'anthropic'/'openai-compat' CUSTOM_CLASSES' own
|
|
95
|
+
* namespaces, which are a different feature (a self-hosted/compatible
|
|
96
|
+
* endpoint's base URL + key) — same store, deliberately separate rows. */
|
|
97
|
+
function byokNamespace(providerId) {
|
|
98
|
+
return `byok:${providerId}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Split a byok model id ("anthropic:claude-sonnet-5") into its provider and
|
|
102
|
+
* bare model parts. `modelId` may itself contain ':' (none of today's
|
|
103
|
+
* catalog ids do, but nothing guarantees that), so only the FIRST segment is
|
|
104
|
+
* the provider — the rest re-joins as the model. */
|
|
105
|
+
function splitByokModel(compound) {
|
|
106
|
+
const s = typeof compound === 'string' ? compound : '';
|
|
107
|
+
const i = s.indexOf(':');
|
|
108
|
+
if (i < 0) return { provider: '', model: s };
|
|
109
|
+
return { provider: s.slice(0, i), model: s.slice(i + 1) };
|
|
110
|
+
}
|
|
111
|
+
|
|
92
112
|
/**
|
|
93
113
|
* Mirrors aegiscodex-dev's src/backend.js DEEPSEEK_REASONING_MODEL_RE +
|
|
94
114
|
* EFFORT_TOKEN_BUDGET verbatim. DeepSeek's reasoning models (deepseek-flash,
|
|
@@ -657,6 +677,16 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
657
677
|
if (c.class === 'aegis') {
|
|
658
678
|
return { ...c, configured: Boolean(aegis.apiKey) };
|
|
659
679
|
}
|
|
680
|
+
if (c.class === 'byok') {
|
|
681
|
+
// Configured means "at least one provider has a locally-stored key",
|
|
682
|
+
// not a single baseURL+key pair like the CUSTOM_CLASSES below — byok
|
|
683
|
+
// holds one row per provider (byokNamespace), so customStatus's shape
|
|
684
|
+
// does not apply here.
|
|
685
|
+
const anyConfigured = (settings.list() || []).some(
|
|
686
|
+
(s) => s && typeof s.provider === 'string' && s.provider.startsWith('byok:') && s.configured
|
|
687
|
+
);
|
|
688
|
+
return { ...c, configured: anyConfigured };
|
|
689
|
+
}
|
|
660
690
|
return { ...c, ...customStatus(c.class) };
|
|
661
691
|
});
|
|
662
692
|
}
|
|
@@ -681,6 +711,41 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
681
711
|
const tags = await ollama.listTags();
|
|
682
712
|
return { class: cls, models: tags.map((t) => ({ id: t.id })) };
|
|
683
713
|
}
|
|
714
|
+
if (cls === 'byok') {
|
|
715
|
+
// The server's catalog names every provider it accepts a key for, the
|
|
716
|
+
// models each unlocks, and whether an AEGIS account key is even needed
|
|
717
|
+
// to ask (it is not — see byokProviders' own docstring). Deliberately
|
|
718
|
+
// NOT gated on aegis.apiKey the way the pooled class above is: BYOK's
|
|
719
|
+
// whole point is a caller who brings their own credential, and the
|
|
720
|
+
// catalog itself answers to an anonymous request.
|
|
721
|
+
let providers = [];
|
|
722
|
+
try {
|
|
723
|
+
const data = await aegis.byokProviders();
|
|
724
|
+
providers = (data && data.providers) || [];
|
|
725
|
+
} catch {
|
|
726
|
+
providers = [];
|
|
727
|
+
}
|
|
728
|
+
const models = [];
|
|
729
|
+
for (const p of providers) {
|
|
730
|
+
if (!p || !p.id) continue;
|
|
731
|
+
const local = settings.get(byokNamespace(p.id)) || {};
|
|
732
|
+
const rawModels = Array.isArray(p.models) ? p.models : [];
|
|
733
|
+
for (const m of rawModels) {
|
|
734
|
+
const modelId = typeof m === 'string' ? m : m && m.id;
|
|
735
|
+
if (!modelId) continue;
|
|
736
|
+
models.push({
|
|
737
|
+
id: `${p.id}:${modelId}`,
|
|
738
|
+
label: `${p.label || p.id} — ${modelId}`,
|
|
739
|
+
provider: p.id,
|
|
740
|
+
configured: Boolean(local.configured),
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return {
|
|
745
|
+
class: cls, models, providers,
|
|
746
|
+
needsProviderKey: models.length > 0 && !models.some((m) => m.configured),
|
|
747
|
+
};
|
|
748
|
+
}
|
|
684
749
|
// Custom endpoints: the model id is the *user's* choice — a provider model
|
|
685
750
|
// name, never a URL. Offering the configured base URL as an `id` meant that
|
|
686
751
|
// leaving the default selection POSTed `model: "https://api.openai.com/v1"`,
|
|
@@ -867,6 +932,29 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
867
932
|
});
|
|
868
933
|
}
|
|
869
934
|
|
|
935
|
+
if (cls === 'byok') {
|
|
936
|
+
// opts.model is still the compound "provider:model" id here — the
|
|
937
|
+
// relay wants them split (a `provider` field plus a bare `model`).
|
|
938
|
+
// opts.apiKey is the PROVIDER key resolved by chat() above (from the
|
|
939
|
+
// byokNamespace(provider) settings row), never the account's own AEGIS
|
|
940
|
+
// key — that one is baked into the `aegis` client already and attached
|
|
941
|
+
// automatically by byokChatCompletion() as X-AEGIS-Key so the account
|
|
942
|
+
// gets billed the handling fee; see client/aegis.js.
|
|
943
|
+
const { provider, model: bareModel } = splitByokModel(opts.model);
|
|
944
|
+
return aegis.byokChatCompletion({
|
|
945
|
+
provider,
|
|
946
|
+
model: bareModel,
|
|
947
|
+
providerKey: opts.apiKey,
|
|
948
|
+
prompt: opts.prompt,
|
|
949
|
+
system: opts.system,
|
|
950
|
+
messages: opts.messages,
|
|
951
|
+
maxTokens: opts.maxTokens,
|
|
952
|
+
stream: true,
|
|
953
|
+
onStream: opts.onDelta,
|
|
954
|
+
signal: opts.signal,
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
|
|
870
958
|
const common = {
|
|
871
959
|
baseURL: opts.cfg.baseURL,
|
|
872
960
|
apiKey: opts.apiKey,
|
|
@@ -887,7 +975,14 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
887
975
|
async function chat(payload, onDelta) {
|
|
888
976
|
const cls = payload && payload.class;
|
|
889
977
|
const model = payload && payload.model;
|
|
890
|
-
|
|
978
|
+
// byok's model id is "provider:model" (see splitByokModel) — strip the
|
|
979
|
+
// provider prefix before pattern-matching, or a DeepSeek reasoning model
|
|
980
|
+
// selected under byok ("byok" model "deepseek:deepseek-v4-flash") never
|
|
981
|
+
// matches DEEPSEEK_REASONING_MODEL_RE's anchored pattern and silently
|
|
982
|
+
// gets no stated budget, which is exactly the "hidden CoT ate the whole
|
|
983
|
+
// default and returned nothing" failure this budget exists to prevent.
|
|
984
|
+
const reasoningModelId = cls === 'byok' ? splitByokModel(model).model : model;
|
|
985
|
+
const maxTokens = reasoningBudget(cls, reasoningModelId, payload && payload.maxTokens, payload && payload.effort);
|
|
891
986
|
// The caller's OWN number, kept apart from `maxTokens` above. That one
|
|
892
987
|
// collapses two different facts into a single value — "the caller stated
|
|
893
988
|
// 4096" and "effort implies 32768" — and the pooled path must treat them
|
|
@@ -937,8 +1032,12 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
937
1032
|
const signal = controller.signal;
|
|
938
1033
|
|
|
939
1034
|
// A caller can opt out of the agent loop entirely (`tools: false`) and get
|
|
940
|
-
// the old single-shot turn back.
|
|
941
|
-
|
|
1035
|
+
// the old single-shot turn back. byok is ALWAYS single-shot: the
|
|
1036
|
+
// stateless relay (aegis.byokChatCompletion, /api/v1/byok/chat/completions)
|
|
1037
|
+
// has no tools/tool_choice parameter at all, so sending schemas here would
|
|
1038
|
+
// build a prompt promising tool access the transport silently drops —
|
|
1039
|
+
// the model would reason about exec/readFile and never see a result.
|
|
1040
|
+
const toolsEnabled = cls !== 'byok' && !(payload && payload.tools === false);
|
|
942
1041
|
const wire = cls === 'anthropic' ? 'anthropic' : 'openai';
|
|
943
1042
|
const toolSchemas = toolsEnabled ? T.toolsFor(wire, { includeSubagent: depth < MAX_SUBAGENT_DEPTH }) : [];
|
|
944
1043
|
const toolChoice = (payload && payload.toolChoice) || null;
|
|
@@ -965,8 +1064,35 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
965
1064
|
const turnCwd = envFor(payload).cwd;
|
|
966
1065
|
|
|
967
1066
|
try {
|
|
968
|
-
|
|
969
|
-
|
|
1067
|
+
// byok's settings row lives under the provider named IN THE MODEL id
|
|
1068
|
+
// ("anthropic:claude-sonnet-5" -> byokNamespace('anthropic')), never
|
|
1069
|
+
// under the literal class name — one flat 'byok' row could not hold
|
|
1070
|
+
// more than one provider's key at a time.
|
|
1071
|
+
const byokParts = cls === 'byok' ? splitByokModel(model) : null;
|
|
1072
|
+
const cfg =
|
|
1073
|
+
cls === 'aegis' || cls === 'ollama' ? {}
|
|
1074
|
+
: cls === 'byok' ? settings.get(byokNamespace(byokParts.provider)) || {}
|
|
1075
|
+
: settings.get(cls) || {};
|
|
1076
|
+
const apiKey =
|
|
1077
|
+
cls === 'aegis' || cls === 'ollama' ? null
|
|
1078
|
+
: cls === 'byok' ? settings.rawKey(byokNamespace(byokParts.provider))
|
|
1079
|
+
: settings.rawKey(cls);
|
|
1080
|
+
|
|
1081
|
+
if (cls === 'byok' && (!byokParts.provider || !byokParts.model)) {
|
|
1082
|
+
const err = new Error(
|
|
1083
|
+
"byok: model must be \"<provider>:<model>\" (pick one from /models) — got " +
|
|
1084
|
+
JSON.stringify(model || '')
|
|
1085
|
+
);
|
|
1086
|
+
err.status = 400;
|
|
1087
|
+
throw err;
|
|
1088
|
+
}
|
|
1089
|
+
if (cls === 'byok' && !apiKey) {
|
|
1090
|
+
const err = new Error(
|
|
1091
|
+
`byok: no key saved for "${byokParts.provider}" — add one before chatting with this model.`
|
|
1092
|
+
);
|
|
1093
|
+
err.status = 400;
|
|
1094
|
+
throw err;
|
|
1095
|
+
}
|
|
970
1096
|
|
|
971
1097
|
// Custom classes carry no enumerable model list (see listModels), so a
|
|
972
1098
|
// blank id here means the user never typed one. Fail loudly in-process
|
package/lib/sync/sessions.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* `sessions.json`, while the terminal host kept `~/.aegiscode/history.jsonl`
|
|
9
9
|
* and the MCP plugin could see neither — three hosts, one account, three
|
|
10
10
|
* disjoint views of the conversation. The implementation now lives in
|
|
11
|
-
* `client/session-store.js`, which is the tree
|
|
11
|
+
* `client/session-store.js`, which is the tree every host already bundles,
|
|
12
12
|
* and every call here forwards to it.
|
|
13
13
|
*
|
|
14
14
|
* Two things that matter are deliberately unchanged:
|
package/main.js
CHANGED
|
@@ -39,7 +39,7 @@ const { createClient } = sharedClient;
|
|
|
39
39
|
// The shared credential store (`client/credentials.js`) — the same 0600 file
|
|
40
40
|
// the terminal host writes with `aegiscode login` and the MCP plugin reads. The
|
|
41
41
|
// app resolves its own account key through it, so signing in once serves all
|
|
42
|
-
//
|
|
42
|
+
// four hosts; see resolveStartupKey() and persistApiKey() below.
|
|
43
43
|
let credentials;
|
|
44
44
|
try {
|
|
45
45
|
credentials = require('../client/credentials.js');
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aegis-desktop",
|
|
3
3
|
"productName": "AEGIS Desktop",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.7",
|
|
5
5
|
"description": "Thin Electron host for AEGIS — a local chat UI over the shared client/aegis.js transport. Ships transport + UI only; engine logic stays server-side.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "AEGIS Code",
|
package/renderer/app.js
CHANGED
|
@@ -296,6 +296,11 @@ function renderRollMeter(sessionId, live) {
|
|
|
296
296
|
roll = rollTurn(roll || emptyRoll(), undefined, {
|
|
297
297
|
prompt: live.prompt,
|
|
298
298
|
reply: live.reply,
|
|
299
|
+
// The thinking trace is billed output too — see `estimatedBuckets`. Left
|
|
300
|
+
// out, this preview measured only the visible answer, so on a reasoning
|
|
301
|
+
// model the meter sat on one number for the whole (longest, priciest)
|
|
302
|
+
// phase of the turn and looked dead while the AI was demonstrably working.
|
|
303
|
+
reasoning: live.reasoning,
|
|
299
304
|
});
|
|
300
305
|
}
|
|
301
306
|
const line = roll ? fmtRoll(roll) : '';
|
|
@@ -331,6 +336,7 @@ function ledgerFields(usage, model, turn, text) {
|
|
|
331
336
|
calls: text && text.calls,
|
|
332
337
|
prompt: text && text.prompt,
|
|
333
338
|
reply: text && text.reply,
|
|
339
|
+
reasoning: text && text.reasoning,
|
|
334
340
|
});
|
|
335
341
|
const fields = row ? Object.assign({}, row) : {};
|
|
336
342
|
if (model) fields.model = model;
|
|
@@ -2652,6 +2658,12 @@ async function loadModels(cls) {
|
|
|
2652
2658
|
hint = null; // carries a link, built below
|
|
2653
2659
|
} else if (!list.length) {
|
|
2654
2660
|
hint = cls === 'ollama' ? 'Ollama not running or no models pulled.' : 'No models listed.';
|
|
2661
|
+
} else if (cls === 'byok' && data && data.needsProviderKey) {
|
|
2662
|
+
// Unlike the pooled 'aegis' class, byok still shows every model here —
|
|
2663
|
+
// the catalog answers with no key at all — but none of them are
|
|
2664
|
+
// usable until a provider key is saved in Provider settings below.
|
|
2665
|
+
hint = `${list.length} model${list.length === 1 ? '' : 's'} available — ` +
|
|
2666
|
+
'add a provider key in Provider settings below to use one.';
|
|
2655
2667
|
} else {
|
|
2656
2668
|
hint = `${list.length} model${list.length === 1 ? '' : 's'} available.`;
|
|
2657
2669
|
}
|
|
@@ -2712,6 +2724,71 @@ function applyCustomPreset(cls, modelId) {
|
|
|
2712
2724
|
|
|
2713
2725
|
// -------------------------------------------------------------- settings pane
|
|
2714
2726
|
|
|
2727
|
+
/**
|
|
2728
|
+
* One provider-settings row: name, an optional base-URL field, a key input,
|
|
2729
|
+
* a status label and Save/Remove buttons wired to the generic
|
|
2730
|
+
* `models.settings.*` surface. Shared by the two custom endpoints (which
|
|
2731
|
+
* need a base URL) and the byok providers (which do not — the server
|
|
2732
|
+
* dictates the endpoint; only the key is theirs to set).
|
|
2733
|
+
*/
|
|
2734
|
+
function buildSettingRow({ provider, name, cfg, showBaseURL, onSave, onRemove }) {
|
|
2735
|
+
const row = document.createElement('div');
|
|
2736
|
+
row.className = 'setting-row';
|
|
2737
|
+
// Targeted by applyCustomPreset() so picking a Model-card preset can
|
|
2738
|
+
// quick-fill the matching base URL here without a full loadSettings()
|
|
2739
|
+
// round trip.
|
|
2740
|
+
row.dataset.provider = provider;
|
|
2741
|
+
|
|
2742
|
+
const label = document.createElement('div');
|
|
2743
|
+
label.className = 'setting-name';
|
|
2744
|
+
label.textContent = name;
|
|
2745
|
+
row.appendChild(label);
|
|
2746
|
+
|
|
2747
|
+
let baseInput = null;
|
|
2748
|
+
if (showBaseURL) {
|
|
2749
|
+
baseInput = document.createElement('input');
|
|
2750
|
+
baseInput.type = 'text';
|
|
2751
|
+
baseInput.className = 'setting-input setting-base';
|
|
2752
|
+
baseInput.placeholder = 'base URL';
|
|
2753
|
+
baseInput.value = cfg.baseURL || '';
|
|
2754
|
+
row.appendChild(baseInput);
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
const keyInput = document.createElement('input');
|
|
2758
|
+
keyInput.type = 'password';
|
|
2759
|
+
keyInput.className = 'setting-input';
|
|
2760
|
+
keyInput.placeholder = cfg.configured
|
|
2761
|
+
? `key ${cfg.keyMask} (blank = keep)`
|
|
2762
|
+
: 'API key';
|
|
2763
|
+
row.appendChild(keyInput);
|
|
2764
|
+
|
|
2765
|
+
const status = document.createElement('div');
|
|
2766
|
+
status.className = 'setting-status';
|
|
2767
|
+
status.textContent = cfg.configured ? `configured (${cfg.keyMask})` : 'no key';
|
|
2768
|
+
row.appendChild(status);
|
|
2769
|
+
|
|
2770
|
+
const actions = document.createElement('div');
|
|
2771
|
+
actions.className = 'setting-actions';
|
|
2772
|
+
|
|
2773
|
+
const saveBtn = document.createElement('button');
|
|
2774
|
+
saveBtn.type = 'button';
|
|
2775
|
+
saveBtn.className = 'ghost-btn';
|
|
2776
|
+
saveBtn.textContent = 'Save';
|
|
2777
|
+
saveBtn.addEventListener('click', () => onSave(baseInput ? baseInput.value.trim() : '', keyInput.value));
|
|
2778
|
+
actions.appendChild(saveBtn);
|
|
2779
|
+
|
|
2780
|
+
const removeBtn = document.createElement('button');
|
|
2781
|
+
removeBtn.type = 'button';
|
|
2782
|
+
removeBtn.className = 'ghost-btn danger';
|
|
2783
|
+
removeBtn.textContent = 'Remove';
|
|
2784
|
+
removeBtn.disabled = !cfg.configured;
|
|
2785
|
+
removeBtn.addEventListener('click', onRemove);
|
|
2786
|
+
actions.appendChild(removeBtn);
|
|
2787
|
+
|
|
2788
|
+
row.appendChild(actions);
|
|
2789
|
+
return row;
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2715
2792
|
async function loadSettings() {
|
|
2716
2793
|
els.settingsList.innerHTML = '';
|
|
2717
2794
|
let settings = [];
|
|
@@ -2736,61 +2813,38 @@ async function loadSettings() {
|
|
|
2736
2813
|
configured: false,
|
|
2737
2814
|
keyMask: null,
|
|
2738
2815
|
};
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
const actions = document.createElement('div');
|
|
2773
|
-
actions.className = 'setting-actions';
|
|
2774
|
-
|
|
2775
|
-
const saveBtn = document.createElement('button');
|
|
2776
|
-
saveBtn.type = 'button';
|
|
2777
|
-
saveBtn.className = 'ghost-btn';
|
|
2778
|
-
saveBtn.textContent = 'Save';
|
|
2779
|
-
saveBtn.addEventListener('click', () =>
|
|
2780
|
-
saveSetting(provider, baseInput.value.trim(), keyInput.value)
|
|
2781
|
-
);
|
|
2782
|
-
actions.appendChild(saveBtn);
|
|
2783
|
-
|
|
2784
|
-
const removeBtn = document.createElement('button');
|
|
2785
|
-
removeBtn.type = 'button';
|
|
2786
|
-
removeBtn.className = 'ghost-btn danger';
|
|
2787
|
-
removeBtn.textContent = 'Remove';
|
|
2788
|
-
removeBtn.disabled = !cfg.configured;
|
|
2789
|
-
removeBtn.addEventListener('click', () => removeSetting(provider));
|
|
2790
|
-
actions.appendChild(removeBtn);
|
|
2791
|
-
|
|
2792
|
-
row.appendChild(actions);
|
|
2793
|
-
els.settingsList.appendChild(row);
|
|
2816
|
+
els.settingsList.appendChild(buildSettingRow({
|
|
2817
|
+
provider, name, cfg, showBaseURL: true,
|
|
2818
|
+
onSave: (baseURL, key) => saveSetting(provider, baseURL, key),
|
|
2819
|
+
onRemove: () => removeSetting(provider),
|
|
2820
|
+
}));
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
// byok: one row per provider the server's catalog names (GET
|
|
2824
|
+
// /api/v1/byok/providers via the engine's listModels('byok')), not a fixed
|
|
2825
|
+
// pair like the two custom endpoints above — the catalog is the source of
|
|
2826
|
+
// truth so a provider added server-side shows up here with no client
|
|
2827
|
+
// release. No base-URL field: byok always talks to AEGIS's own relay
|
|
2828
|
+
// (/api/v1/byok/chat/completions), which is what attaches the AEGIS key
|
|
2829
|
+
// and makes the call billable — the provider key typed here authenticates
|
|
2830
|
+
// to the UPSTREAM provider only.
|
|
2831
|
+
try {
|
|
2832
|
+
const byokData = await models.listModels('byok');
|
|
2833
|
+
const byokProviders = Array.isArray(byokData && byokData.providers) ? byokData.providers : [];
|
|
2834
|
+
for (const p of byokProviders) {
|
|
2835
|
+
if (!p || !p.id) continue;
|
|
2836
|
+
const provider = `byok:${p.id}`;
|
|
2837
|
+
const local = settings.find((s) => s.provider === provider) || {
|
|
2838
|
+
provider, baseURL: '', configured: false, keyMask: null,
|
|
2839
|
+
};
|
|
2840
|
+
els.settingsList.appendChild(buildSettingRow({
|
|
2841
|
+
provider, name: `BYOK: ${p.label || p.id}`, cfg: local, showBaseURL: false,
|
|
2842
|
+
onSave: (_baseURL, key) => saveSetting(provider, '', key),
|
|
2843
|
+
onRemove: () => removeSetting(provider),
|
|
2844
|
+
}));
|
|
2845
|
+
}
|
|
2846
|
+
} catch {
|
|
2847
|
+
/* catalog unreachable (offline, server down) — the two custom rows above still work */
|
|
2794
2848
|
}
|
|
2795
2849
|
}
|
|
2796
2850
|
|
|
@@ -3168,7 +3222,7 @@ async function send() {
|
|
|
3168
3222
|
// Live estimate so the topbar meter keeps moving while the reply streams
|
|
3169
3223
|
// in, instead of sitting frozen on the previous turn's total until this
|
|
3170
3224
|
// one resolves — see renderRollMeter's `live` param.
|
|
3171
|
-
renderRollMeter(sessionId, { prompt, reply: streamedText });
|
|
3225
|
+
renderRollMeter(sessionId, { prompt, reply: streamedText, reasoning: reasoningText });
|
|
3172
3226
|
stickToBottom();
|
|
3173
3227
|
});
|
|
3174
3228
|
|
|
@@ -3248,7 +3302,7 @@ async function send() {
|
|
|
3248
3302
|
// count is never read as a reported one. Printing nothing here while the
|
|
3249
3303
|
// session total moved was the other half of "the counter looks dead".
|
|
3250
3304
|
else {
|
|
3251
|
-
const est = estimatedBuckets(prompt, text);
|
|
3305
|
+
const est = estimatedBuckets(prompt, text, text === reasoningText ? '' : reasoningText);
|
|
3252
3306
|
if (est) bits.push(`~${est.input + est.output} tokens`);
|
|
3253
3307
|
}
|
|
3254
3308
|
if (turn.cost != null) bits.push(fmtCost(turn.cost, turn.real));
|
|
@@ -3267,6 +3321,10 @@ async function send() {
|
|
|
3267
3321
|
// CLI's `appendHistory` rule and the reason the total moves every turn.
|
|
3268
3322
|
prompt,
|
|
3269
3323
|
reply: text,
|
|
3324
|
+
// The thinking trace is billed output and is not part of `text`, so it is
|
|
3325
|
+
// counted here too — guarded, because the same string must never be
|
|
3326
|
+
// estimated twice if a turn ever collapses the two into one.
|
|
3327
|
+
reasoning: text === reasoningText ? '' : reasoningText,
|
|
3270
3328
|
});
|
|
3271
3329
|
const rollLine = fmtRoll(roll);
|
|
3272
3330
|
if (rollLine) bits.push(`session: ${rollLine}`);
|
|
@@ -3282,6 +3340,10 @@ async function send() {
|
|
|
3282
3340
|
...ledgerFields(data && data.usage, model, turn, {
|
|
3283
3341
|
prompt,
|
|
3284
3342
|
reply: text,
|
|
3343
|
+
// The thinking trace is billed output and is not part of `text`; it
|
|
3344
|
+
// has to persist too, or reopening the window rebuilds a roll short
|
|
3345
|
+
// by the longest part of the turn.
|
|
3346
|
+
reasoning: text === reasoningText ? '' : reasoningText,
|
|
3285
3347
|
calls: data && data.calls,
|
|
3286
3348
|
}),
|
|
3287
3349
|
});
|
|
@@ -3316,11 +3378,16 @@ async function send() {
|
|
|
3316
3378
|
// on this path the figure is the text estimate, marked `est` — and
|
|
3317
3379
|
// never a fabricated zero.
|
|
3318
3380
|
const turn = turnAccounting(undefined, model, {});
|
|
3319
|
-
const roll = foldRoll(sessionId, undefined, {
|
|
3381
|
+
const roll = foldRoll(sessionId, undefined, {
|
|
3382
|
+
model,
|
|
3383
|
+
prompt,
|
|
3384
|
+
reply: text,
|
|
3385
|
+
reasoning: text === reasoningText ? '' : reasoningText,
|
|
3386
|
+
});
|
|
3320
3387
|
const stopBits = ['stopped by you'];
|
|
3321
3388
|
if (turn.tokens != null) stopBits.push(`tokens: ${turn.tokens}`);
|
|
3322
3389
|
else {
|
|
3323
|
-
const est = estimatedBuckets(prompt, text);
|
|
3390
|
+
const est = estimatedBuckets(prompt, text, text === reasoningText ? '' : reasoningText);
|
|
3324
3391
|
if (est) stopBits.push(`~${est.input + est.output} tokens`);
|
|
3325
3392
|
}
|
|
3326
3393
|
const stopRollLine = fmtRoll(roll);
|
|
@@ -3330,7 +3397,11 @@ async function send() {
|
|
|
3330
3397
|
await sync.append(sessionId, {
|
|
3331
3398
|
role: 'assistant',
|
|
3332
3399
|
content: text,
|
|
3333
|
-
...ledgerFields(undefined, model, turn, {
|
|
3400
|
+
...ledgerFields(undefined, model, turn, {
|
|
3401
|
+
prompt,
|
|
3402
|
+
reply: text,
|
|
3403
|
+
reasoning: text === reasoningText ? '' : reasoningText,
|
|
3404
|
+
}),
|
|
3334
3405
|
});
|
|
3335
3406
|
} catch {
|
|
3336
3407
|
/* persistence is non-fatal */
|
package/renderer/usage.js
CHANGED
|
@@ -99,17 +99,29 @@ function estimateTokens(text) {
|
|
|
99
99
|
* genuinely reported nothing (no usage, no prompt, no reply) still lands in
|
|
100
100
|
* `unknown` instead of being handed a fabricated zero.
|
|
101
101
|
*
|
|
102
|
+
* `reasoning` is the extended-thinking trace, and it is OUTPUT: a reasoning
|
|
103
|
+
* model bills its chain of thought as output tokens, which is why the wire's
|
|
104
|
+
* own `output_tokens` already includes it. The desktop renders that trace in
|
|
105
|
+
* its own element (app.js `reasoningText`) rather than in the reply body, so
|
|
106
|
+
* the two streams had to be added back together here — an estimate measured off
|
|
107
|
+
* the visible answer alone sat still for the entire think phase, which on a
|
|
108
|
+
* reasoning model is both the longest and the most expensive part of the turn.
|
|
109
|
+
*
|
|
102
110
|
* @param {string} [prompt]
|
|
103
111
|
* @param {string} [reply]
|
|
112
|
+
* @param {string} [reasoning]
|
|
104
113
|
* @returns {{input: number, output: number, cacheRead: number, cacheWrite: number}|null}
|
|
105
114
|
*/
|
|
106
|
-
function estimatedBuckets(prompt, reply) {
|
|
115
|
+
function estimatedBuckets(prompt, reply, reasoning) {
|
|
107
116
|
const hasPrompt = typeof prompt === 'string' && prompt.length > 0;
|
|
108
|
-
const
|
|
117
|
+
const out =
|
|
118
|
+
(typeof reply === 'string' ? reply : '') +
|
|
119
|
+
(typeof reasoning === 'string' ? reasoning : '');
|
|
120
|
+
const hasReply = out.length > 0;
|
|
109
121
|
if (!hasPrompt && !hasReply) return null;
|
|
110
122
|
return {
|
|
111
123
|
input: estimateTokens(prompt),
|
|
112
|
-
output: estimateTokens(
|
|
124
|
+
output: estimateTokens(out),
|
|
113
125
|
cacheRead: 0,
|
|
114
126
|
cacheWrite: 0,
|
|
115
127
|
real: false,
|
|
@@ -250,6 +262,8 @@ function turnAccounting(usage, model, opts = {}) {
|
|
|
250
262
|
* back is that RUNNING TOTAL. The status bar prints `state.tokens`
|
|
251
263
|
* (renderStatus), and `ctrl+t` prints the tallies in one line
|
|
252
264
|
* (`tokenSummary`: `12,400 tok (10,100 in / 2,300 out) · 4 calls · €0.03`).
|
|
265
|
+
* The tallies are reproduced; that one abbreviation is not — `fmtRoll` explains
|
|
266
|
+
* why it drops the parenthetical rather than carrying the CLI's line verbatim.
|
|
253
267
|
*
|
|
254
268
|
* The desktop counted per turn only. Every meta row was a fresh count that
|
|
255
269
|
* reset at the next call, so "what has this session spent" was answerable only
|
|
@@ -337,7 +351,7 @@ function rollTurn(roll, usage, opts = {}) {
|
|
|
337
351
|
// estimate here is what makes the live roll and the rebuilt roll the same
|
|
338
352
|
// number, and what stops the total standing still on exactly the turns the
|
|
339
353
|
// pool declined to report on — the symptom this fallback exists to remove.
|
|
340
|
-
const est = estimatedBuckets(opts.prompt, opts.reply);
|
|
354
|
+
const est = estimatedBuckets(opts.prompt, opts.reply, opts.reasoning);
|
|
341
355
|
if (!est) {
|
|
342
356
|
// Nothing reported AND no text to estimate from. The single case that
|
|
343
357
|
// stays uncounted: `unknown` names the gap, where a fabricated zero would
|
|
@@ -439,7 +453,10 @@ function ledgerRow(usage, turn, opts = {}) {
|
|
|
439
453
|
if (calls !== undefined) row.calls = calls;
|
|
440
454
|
return row;
|
|
441
455
|
}
|
|
442
|
-
|
|
456
|
+
// `reasoning` is part of the billed output, so it is estimated with the
|
|
457
|
+
// reply — a stored row for a thinking-heavy turn must not persist a count
|
|
458
|
+
// that excludes the longest thing the model wrote.
|
|
459
|
+
const est = estimatedBuckets(opts.prompt, opts.reply, opts.reasoning);
|
|
443
460
|
if (!est) return null;
|
|
444
461
|
const row = { tokens: est };
|
|
445
462
|
if (calls !== undefined) row.calls = calls;
|
|
@@ -471,28 +488,30 @@ function fmtTokens(n) {
|
|
|
471
488
|
* an untouched session adds no noise to a turn's meta line.
|
|
472
489
|
*
|
|
473
490
|
* @param {object} [roll]
|
|
474
|
-
* @returns {string} e.g. `12,400 tok
|
|
491
|
+
* @returns {string} e.g. `12,400 tok · 4 calls · $0.0310` — the CLI's fields in
|
|
492
|
+
* the CLI's order, minus the input/output parenthetical (see below).
|
|
475
493
|
*/
|
|
476
494
|
function fmtRoll(roll) {
|
|
477
495
|
const r = roll || emptyRoll();
|
|
478
496
|
if (!r.turns && !r.calls) return '';
|
|
479
|
-
// The
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
497
|
+
// The fields are `tokenSummary`'s (cli/src/app.js:1413) in its order — tokens,
|
|
498
|
+
// calls, money — with one dropped: the `(10,100 in / 2,300 out)` split. `in`
|
|
499
|
+
// and `out` are a terminal status-line shorthand, legible to someone already
|
|
500
|
+
// reading that status line and to nobody else, which is what an unlabelled
|
|
501
|
+
// abbreviation in a GUI topbar turns into. The total is the figure a reader
|
|
502
|
+
// wants; `r.input`/`r.output` are still folded onto the roll (see `rollTurn`)
|
|
503
|
+
// for any surface that wants to show the split with real labels. The call
|
|
504
|
+
// count stays unconditional, because it is the number that reveals a
|
|
505
|
+
// fan-out, and it is not hidden at 1.
|
|
485
506
|
const bits = [];
|
|
486
507
|
// The token half only when something was actually counted. `rollTurn` counts
|
|
487
508
|
// turns and calls BEFORE it looks at the token count, so a dispatch that
|
|
488
509
|
// reported nothing still reaches here — and printing its empty tally would
|
|
489
|
-
// put `0 tok
|
|
490
|
-
//
|
|
491
|
-
//
|
|
510
|
+
// put `0 tok` on the topbar, a figure the meter never took, which reads as a
|
|
511
|
+
// counter that does not move. The turn count is still stated, because that
|
|
512
|
+
// much is true.
|
|
492
513
|
if (r.tokens > 0) {
|
|
493
|
-
bits.push(
|
|
494
|
-
`${fmtTokens(r.tokens)} tok (${fmtTokens(r.input)} in / ${fmtTokens(r.output)} out)`
|
|
495
|
-
);
|
|
514
|
+
bits.push(`${fmtTokens(r.tokens)} tok`);
|
|
496
515
|
}
|
|
497
516
|
bits.push(`${r.calls} call${r.calls === 1 ? '' : 's'}`);
|
|
498
517
|
// Money is where this line departs from `tokenSummary`, deliberately: that
|
package/vendor/aegis.js
CHANGED
|
@@ -7,10 +7,13 @@
|
|
|
7
7
|
* orchestration, routing, or tier logic. All of that lives in the private
|
|
8
8
|
* ae-guix product and behind aegiscloud.org.
|
|
9
9
|
*
|
|
10
|
-
* Runs unchanged under
|
|
10
|
+
* Runs unchanged under four hosts:
|
|
11
11
|
* - mcp/server.js (Claude Code MCP plugin) — CommonJS require
|
|
12
12
|
* - desktop/ (thin Electron shell) — CommonJS require
|
|
13
13
|
* (vendored byte-identical copy at desktop/vendor/aegis.js)
|
|
14
|
+
* - cli/ (aegiscode terminal host) — CommonJS require
|
|
15
|
+
* (vendored byte-identical copy at
|
|
16
|
+
* cli/vendor/client/aegis.js)
|
|
14
17
|
* - aegis-online (browser SPA, vendored copy) — <script> tag →
|
|
15
18
|
* window.AegisClient
|
|
16
19
|
*
|
|
@@ -348,6 +351,12 @@ function createClient(opts = {}) {
|
|
|
348
351
|
* provider key never touches AEGIS storage — it is forwarded straight to the
|
|
349
352
|
* provider for this request only. Mirrors chatCompletion()'s streaming /
|
|
350
353
|
* fallback semantics exactly.
|
|
354
|
+
*
|
|
355
|
+
* The caller's *AEGIS* key is sent alongside the provider key as X-AEGIS-Key.
|
|
356
|
+
* Two credentials, two headers, on purpose: the provider key authenticates the
|
|
357
|
+
* upstream call, the AEGIS key says whose bank pays the handling fee. With no
|
|
358
|
+
* AEGIS key configured the header is omitted and the call stays anonymous —
|
|
359
|
+
* allowed, just unbilled (see _byok_identify_user on the server).
|
|
351
360
|
*/
|
|
352
361
|
async function byokChatCompletion({
|
|
353
362
|
provider = 'openai',
|
|
@@ -373,6 +382,10 @@ function createClient(opts = {}) {
|
|
|
373
382
|
'X-AEGIS-Version': clientVersion,
|
|
374
383
|
'X-Provider-Key': key,
|
|
375
384
|
};
|
|
385
|
+
// Identify the payer. Absent key => anonymous call, which the server
|
|
386
|
+
// accepts but cannot bill; sending it is what turns a BYOK turn into
|
|
387
|
+
// billable traffic instead of a free ride.
|
|
388
|
+
if (apiKey) headers['X-AEGIS-Key'] = apiKey;
|
|
376
389
|
if (!stream || typeof onStream !== 'function') {
|
|
377
390
|
return apiPost('/api/v1/byok/chat/completions', { ...body, stream: false }, headers);
|
|
378
391
|
}
|
|
@@ -683,6 +696,15 @@ function createClient(opts = {}) {
|
|
|
683
696
|
return apiGet('/api/user/api-keys');
|
|
684
697
|
}
|
|
685
698
|
|
|
699
|
+
/** The server's BYOK provider catalog: which providers accept a key, which
|
|
700
|
+
* models each unlocks, where to get the key, and the prefix a valid key
|
|
701
|
+
* starts with. Deliberately NOT key-gated — "which key do I go get, and
|
|
702
|
+
* what will it unlock?" is the question asked *before* an AEGIS key exists.
|
|
703
|
+
* An AEGIS key, if present, only adds the `configured` column. */
|
|
704
|
+
async function byokProviders() {
|
|
705
|
+
return apiGet('/api/v1/byok/providers');
|
|
706
|
+
}
|
|
707
|
+
|
|
686
708
|
async function byokSet(provider, providerApiKey) {
|
|
687
709
|
return apiPost('/api/user/api-keys', {
|
|
688
710
|
provider,
|
|
@@ -809,6 +831,7 @@ function createClient(opts = {}) {
|
|
|
809
831
|
tokenBankTopup,
|
|
810
832
|
billingCheckout,
|
|
811
833
|
byokStatus,
|
|
834
|
+
byokProviders,
|
|
812
835
|
byokSet,
|
|
813
836
|
getMemoryToken,
|
|
814
837
|
memorySearch,
|
package/vendor/credentials.js
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
* credentials.js — the ONE AEGIS account credential store, shared by every
|
|
5
5
|
* host in this repo (terminal `aegiscode`, the MCP plugin, the desktop app).
|
|
6
6
|
*
|
|
7
|
-
* Why it lives in `client/`: this directory is the only tree
|
|
8
|
-
* already
|
|
7
|
+
* Why it lives in `client/`: this directory is the only tree every host
|
|
8
|
+
* already bundles. The MCP plugin ships `mcp/` + `client/` and nothing else, so
|
|
9
9
|
* a reader placed here needs no cross-package dependency — which is what the
|
|
10
10
|
* alternative (the MCP host requiring `cli/src/credentials.js`) would have
|
|
11
11
|
* forced, and why that host used to read `AEGIS_API_KEY` from the environment
|
|
12
|
-
* alone while the CLI could save a key to disk. One login, one file,
|
|
13
|
-
* hosts: that is the point of this module.
|
|
12
|
+
* alone while the CLI could save a key to disk. One login, one file, all
|
|
13
|
+
* four hosts: that is the point of this module.
|
|
14
14
|
*
|
|
15
15
|
* Zero dependencies, no host imports. The data dir is resolved here
|
|
16
16
|
* (`aegisHome()`) rather than imported from the CLI's config.js, so this file
|