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.
@@ -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
- const maxTokens = reasoningBudget(cls, model, payload && payload.maxTokens, payload && payload.effort);
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
- const toolsEnabled = !(payload && payload.tools === false);
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
- const cfg = cls === 'aegis' || cls === 'ollama' ? {} : settings.get(cls) || {};
969
- const apiKey = cls === 'aegis' || cls === 'ollama' ? null : settings.rawKey(cls);
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
@@ -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 all three hosts already bundle,
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
- // three hosts; see resolveStartupKey() and persistApiKey() below.
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.5",
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
- const row = document.createElement('div');
2741
- row.className = 'setting-row';
2742
- // Targeted by applyCustomPreset() so picking a Model-card preset can
2743
- // quick-fill the matching base URL here without a full loadSettings()
2744
- // round trip.
2745
- row.dataset.provider = provider;
2746
-
2747
- const label = document.createElement('div');
2748
- label.className = 'setting-name';
2749
- label.textContent = name;
2750
- row.appendChild(label);
2751
-
2752
- const baseInput = document.createElement('input');
2753
- baseInput.type = 'text';
2754
- baseInput.className = 'setting-input setting-base';
2755
- baseInput.placeholder = 'base URL';
2756
- baseInput.value = cfg.baseURL || '';
2757
- row.appendChild(baseInput);
2758
-
2759
- const keyInput = document.createElement('input');
2760
- keyInput.type = 'password';
2761
- keyInput.className = 'setting-input';
2762
- keyInput.placeholder = cfg.configured
2763
- ? `key ${cfg.keyMask} (blank = keep)`
2764
- : 'API key';
2765
- row.appendChild(keyInput);
2766
-
2767
- const status = document.createElement('div');
2768
- status.className = 'setting-status';
2769
- status.textContent = cfg.configured ? `configured (${cfg.keyMask})` : 'no key';
2770
- row.appendChild(status);
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, { model, prompt, reply: text });
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, { prompt, reply: text }),
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 hasReply = typeof reply === 'string' && reply.length > 0;
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(reply),
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
- const est = estimatedBuckets(opts.prompt, opts.reply);
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 (10,100 in / 2,300 out) · 4 calls · $0.0310`
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 first two fields are `tokenSummary` (cli/src/app.js:1413) verbatim,
480
- // separator and all: `12,400 tok (10,100 in / 2,300 out) · 4 calls`. There the
481
- // parenthetical is unconditional and the call count is singular at one; both
482
- // are kept, because a line that is only *sometimes* shaped like the CLI's is a
483
- // lookalike rather than the same quantity. The call count is also the number
484
- // that reveals a fan-out, which is why it is not hidden at 1.
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 (0 in / 0 out)` on the topbar, a figure the meter never took,
490
- // which reads as a counter that does not move. The turn count is still
491
- // stated, because that much is true.
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 three hosts:
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,
@@ -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 all three hosts
8
- * already bundle. The MCP plugin ships `mcp/` + `client/` and nothing else, so
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, three
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