aegis-desktop 0.8.32 → 0.8.34
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/main.js +12 -0
- package/package.json +1 -1
- package/renderer/app.js +236 -31
- package/renderer/index.html +2 -3
- package/renderer/stream-policy.js +24 -2
- package/renderer/style.css +49 -7
- package/renderer/usage.js +206 -20
package/main.js
CHANGED
|
@@ -1748,6 +1748,18 @@ function registerModelIpc(ipcMain, engine, sessionsDir, aegis, onReplyFinished,
|
|
|
1748
1748
|
if (name === 'chat') {
|
|
1749
1749
|
ipcMain.handle(`${MODEL_PREFIX}${name}`, (event, payload) => {
|
|
1750
1750
|
const opts = { ...(payload || {}) };
|
|
1751
|
+
// The rung every renderer sends is low|medium|high; this is the one
|
|
1752
|
+
// place a stale window can still hand us the retired `auto` (a page
|
|
1753
|
+
// loaded before the select lost that row, or a renderer that forgot to
|
|
1754
|
+
// resolve), and a falsy effort is NOT "the engine's default" — the
|
|
1755
|
+
// shared engine states the field conditionally, so an absent value
|
|
1756
|
+
// means "state no opinion" and the server sizes the turn itself. So it
|
|
1757
|
+
// is normalised here rather than passed through: an unknown value
|
|
1758
|
+
// becomes `high`, the same floor the engine and the CLI apply, and no
|
|
1759
|
+
// turn can reach the wire with an effort nobody chose.
|
|
1760
|
+
const effortRung =
|
|
1761
|
+
opts.effort === 'low' || opts.effort === 'medium' ? opts.effort : 'high';
|
|
1762
|
+
opts.effort = effortRung;
|
|
1751
1763
|
const sender = event && event.sender;
|
|
1752
1764
|
const forward = (chunk) => {
|
|
1753
1765
|
if (
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aegis-desktop",
|
|
3
3
|
"productName": "AEGIS Desktop",
|
|
4
|
-
"version": "0.8.
|
|
4
|
+
"version": "0.8.34",
|
|
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
|
@@ -490,6 +490,22 @@ function rollFor(sessionId) {
|
|
|
490
490
|
* per-turn figure beside the session total (the CLI shows both: the meta row's
|
|
491
491
|
* `1.5k tok` and the status bar's rolling total).
|
|
492
492
|
*/
|
|
493
|
+
/**
|
|
494
|
+
* The account's entitlement snapshot, as the main process last reported it.
|
|
495
|
+
*
|
|
496
|
+
* One reader for the money rule, so the rows that print a charge cannot disagree
|
|
497
|
+
* with each other about which tier they are on — `feeWaived` in usage.js holds
|
|
498
|
+
* the rule itself. `state` is read defensively because this runs on paths that
|
|
499
|
+
* can fire before the first entitlement probe has answered: with no snapshot yet
|
|
500
|
+
* there is no proven tier, and `feeWaived(null)` says "not waived", which is the
|
|
501
|
+
* only safe reading of an unproven account (it is the same reason
|
|
502
|
+
* `entitlement.js` starts at the free boundary rather than at Pro).
|
|
503
|
+
*/
|
|
504
|
+
function entitlement() {
|
|
505
|
+
const s = typeof state !== 'undefined' ? state : null;
|
|
506
|
+
return s && s.entitlement ? s.entitlement : null;
|
|
507
|
+
}
|
|
508
|
+
|
|
493
509
|
function foldRoll(sessionId, usage, opts = {}) {
|
|
494
510
|
const id = sessionId || '';
|
|
495
511
|
const next = rollTurn(rollFor(id), usage, opts);
|
|
@@ -500,7 +516,7 @@ function foldRoll(sessionId, usage, opts = {}) {
|
|
|
500
516
|
|
|
501
517
|
/**
|
|
502
518
|
* Paint the topbar meter. Hidden while a session has accounted for nothing —
|
|
503
|
-
* an unused thread must not display a `0
|
|
519
|
+
* an unused thread must not display a `0 tokens` it never measured — and shown
|
|
504
520
|
* the moment a turn is folded in.
|
|
505
521
|
*
|
|
506
522
|
* `live`, when given, previews the in-flight turn on top of the session's
|
|
@@ -517,6 +533,23 @@ function foldRoll(sessionId, usage, opts = {}) {
|
|
|
517
533
|
*/
|
|
518
534
|
function renderRollMeter(sessionId, live) {
|
|
519
535
|
const el = els.sessionMeter;
|
|
536
|
+
|
|
537
|
+
// The bubble's live figure is painted FIRST, before any of the topbar's own
|
|
538
|
+
// gates can return early. It belongs to the turn in flight — it is the thing
|
|
539
|
+
// the reader is watching while the AI works — and it used to sit at the
|
|
540
|
+
// BOTTOM of this function, behind `if (currentSessionId !== sessionId)
|
|
541
|
+
// return` and behind `if (!el) return`. So a turn whose sessionId did not
|
|
542
|
+
// match the current view, or any window where the topbar element was
|
|
543
|
+
// missing, streamed to completion with a dead counter in the bubble even
|
|
544
|
+
// though the estimate was being recomputed here every frame. That is the
|
|
545
|
+
// "tokens don't work while the AI is working" report.
|
|
546
|
+
const liveCount = live ? liveTokenFig(live) : '';
|
|
547
|
+
const tokensEl = live ? liveTokensEl() : null;
|
|
548
|
+
if (tokensEl) {
|
|
549
|
+
tokensEl.textContent = liveCount;
|
|
550
|
+
tokensEl.hidden = !liveCount;
|
|
551
|
+
}
|
|
552
|
+
|
|
520
553
|
if (!el) return;
|
|
521
554
|
if (currentSessionId !== sessionId) { el.hidden = true; return; }
|
|
522
555
|
let roll = rollsBySession.get(sessionId || '');
|
|
@@ -534,7 +567,115 @@ function renderRollMeter(sessionId, live) {
|
|
|
534
567
|
const line = roll ? fmtRoll(roll) : '';
|
|
535
568
|
el.textContent = line;
|
|
536
569
|
el.hidden = !line;
|
|
570
|
+
// A live figure is an estimate of a turn still in flight; the class is what
|
|
571
|
+
// says so, so a reader is never left comparing a moving number with a settled
|
|
572
|
+
// one and concluding the meter lies.
|
|
573
|
+
el.classList.toggle('live', !!live && !!line);
|
|
537
574
|
if (line) el.title = 'This session, counted the way the CLI counts it — every turn rolled into one running total';
|
|
575
|
+
return roll;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* The bubble's live figure — `↓N tokens`, the CLI's live spinner tail — for a
|
|
580
|
+
* turn still in flight, or '' when there is nothing measured yet.
|
|
581
|
+
*
|
|
582
|
+
* OUTPUT only (the answer plus the thinking trace it was billed with), never
|
|
583
|
+
* input + output: this figure has to be the same quantity the CLI's spinner
|
|
584
|
+
* reports (`cli/src/chatflow.js`: `↓${fmtTokens(state.streamed)} tokens`, fed
|
|
585
|
+
* `streamedCells() / 4`) and the same one aegiscodex-dev climbs with while it
|
|
586
|
+
* works. Adding the prompt in made this surface count a second, much larger
|
|
587
|
+
* quantity that opened high and barely moved.
|
|
588
|
+
*
|
|
589
|
+
* Two ways to fail closed on the same number, because a dead counter is worse
|
|
590
|
+
* than a roughly right one — it tells the reader the turn has stalled:
|
|
591
|
+
*
|
|
592
|
+
* 1. `estimatedBuckets` is the authority. If it is unavailable or its shape
|
|
593
|
+
* is not what this call site expects, the throw used to escape into the
|
|
594
|
+
* painter every frame, so the figure never appeared at all while the
|
|
595
|
+
* answer streamed normally beside it. Caught.
|
|
596
|
+
* 2. Below that, the estimate is taken from the stream itself — the reply's
|
|
597
|
+
* own text at the 4:1 chars:tokens ratio of the same heuristic, i.e. the
|
|
598
|
+
* quantity the CLI's tail is built on. So there is a number to show from
|
|
599
|
+
* the first chunk regardless of what the model reports or bills.
|
|
600
|
+
*/
|
|
601
|
+
function liveTokenFig(live) {
|
|
602
|
+
const reply = typeof live.reply === 'string' ? live.reply : '';
|
|
603
|
+
const reasoning = typeof live.reasoning === 'string' ? live.reasoning : '';
|
|
604
|
+
// A turn in flight that has produced nothing YET. Its figure is zero, and
|
|
605
|
+
// zero is what the other measured surface shows at this moment: the CLI's
|
|
606
|
+
// spinner tail counts the answer's deltas and the thinking trace's, so it too
|
|
607
|
+
// reads `↓0 tokens` only until the turn's first billed token.
|
|
608
|
+
//
|
|
609
|
+
// This is the ONE place the figure deliberately differs from the reference
|
|
610
|
+
// engine's, and the difference is the source of the number rather than its
|
|
611
|
+
// format: `aegiscodex-dev/src/main.js` climbs on ELAPSED TIME
|
|
612
|
+
// (`Math.round(secs * 21 + 7)` with `secs = Math.max(1, …)`, so its floor is
|
|
613
|
+
// 28 and it never prints 0) while this host reports what the turn actually
|
|
614
|
+
// produced. Matching the reference frame-for-frame would mean printing a
|
|
615
|
+
// count no provider ever sent, which this repo refuses everywhere else
|
|
616
|
+
// (`fmtRoll` will not put `0 tokens` on a meter that never took a reading;
|
|
617
|
+
// `fmtUsageSplit` omits an unmeasured half rather than inventing one). The
|
|
618
|
+
// measured zero is honest and self-correcting within the first token.
|
|
619
|
+
//
|
|
620
|
+
// Returning '' here was the start-of-turn lag — the bubble opened with no
|
|
621
|
+
// figure at all and popped one in seconds later, once the reply had grown past
|
|
622
|
+
// a threshold. Safe because the caller only passes `live` for a turn still
|
|
623
|
+
// running: this is a statement about that turn, not a fabricated measurement
|
|
624
|
+
// of a finished one.
|
|
625
|
+
if (!reply && !reasoning) return '↓0 tokens';
|
|
626
|
+
let output = 0;
|
|
627
|
+
try {
|
|
628
|
+
const est = estimatedBuckets(live.prompt, reply, reasoning);
|
|
629
|
+
if (est && typeof est.output === 'number' && est.output > 0) output = est.output;
|
|
630
|
+
} catch (err) {
|
|
631
|
+
/* the stream-text estimate below is the whole point of the fallback */
|
|
632
|
+
}
|
|
633
|
+
if (!output) {
|
|
634
|
+
// Counted, never spread. `[...part].length` is the readable spelling of
|
|
635
|
+
// "how many codepoints", and it is the one this fallback started with — but
|
|
636
|
+
// this function runs inside the painter, so on every frame of every turn
|
|
637
|
+
// that takes this path it built a full codepoint array of the whole reply
|
|
638
|
+
// and threw it away: 100k entries per frame, ~60 frames a second, on the
|
|
639
|
+
// largest replies. The codepoint walk below yields the identical count
|
|
640
|
+
// without the array (a surrogate pair is one codepoint, two UTF-16 units),
|
|
641
|
+
// so the figure is unchanged and the per-frame allocation is gone.
|
|
642
|
+
let cells = 0;
|
|
643
|
+
for (const part of [reply, reasoning]) {
|
|
644
|
+
let i = 0;
|
|
645
|
+
while (i < part.length) {
|
|
646
|
+
const cp = part.codePointAt(i);
|
|
647
|
+
i += cp > 0xffff ? 2 : 1;
|
|
648
|
+
cells += 1;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
output = Math.ceil(cells / 4);
|
|
652
|
+
}
|
|
653
|
+
if (!output) return '';
|
|
654
|
+
return `↓${Math.round(output)} tokens`;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* The live figure's element, CREATED ON DEMAND when the row does not have one.
|
|
659
|
+
*
|
|
660
|
+
* `setBusy` appends it, but not every working row goes through `setBusy`: a
|
|
661
|
+
* turn opened by `ensurePendingRow` (tool activity arriving before any text)
|
|
662
|
+
* had no `.typing-tokens` to find, so the meter had nowhere to paint. Built
|
|
663
|
+
* here, a sibling of `.body` for the same reason as there — `paintStream`
|
|
664
|
+
* assigns the body's `textContent` wholesale and would delete a child.
|
|
665
|
+
*
|
|
666
|
+
* Painted by `renderRollMeter`, the same authority that writes the topbar
|
|
667
|
+
* meter, so the two can never disagree.
|
|
668
|
+
*/
|
|
669
|
+
function liveTokensEl() {
|
|
670
|
+
if (!pendingEl) return null;
|
|
671
|
+
let el = pendingEl.querySelector('.typing-tokens');
|
|
672
|
+
if (!el) {
|
|
673
|
+
el = document.createElement('span');
|
|
674
|
+
el.className = 'typing-tokens';
|
|
675
|
+
el.hidden = true;
|
|
676
|
+
pendingEl.appendChild(el);
|
|
677
|
+
}
|
|
678
|
+
return el;
|
|
538
679
|
}
|
|
539
680
|
|
|
540
681
|
/**
|
|
@@ -833,8 +974,8 @@ function currentBudgetModel() {
|
|
|
833
974
|
*/
|
|
834
975
|
function budgetNote(cls, model) {
|
|
835
976
|
const chosen = effortFor();
|
|
836
|
-
const rung = chosen
|
|
837
|
-
const rungText = chosen
|
|
977
|
+
const rung = chosen;
|
|
978
|
+
const rungText = chosen;
|
|
838
979
|
if (cls === AUTONOMOUS_CLASS) {
|
|
839
980
|
// aegis1 services/pool_brain.py pass_budgets — the total across the worker
|
|
840
981
|
// fan-out + synthesis pass, which is why it is not the local table.
|
|
@@ -864,16 +1005,20 @@ function updateBudgetControls(cls, model) {
|
|
|
864
1005
|
}
|
|
865
1006
|
|
|
866
1007
|
/**
|
|
867
|
-
* The effort to send
|
|
1008
|
+
* The effort rung to send: always 'low' | 'medium' | 'high'.
|
|
868
1009
|
*
|
|
869
|
-
* Sent for EVERY class and model
|
|
870
|
-
*
|
|
871
|
-
*
|
|
872
|
-
*
|
|
1010
|
+
* Sent for EVERY class and model. The three-rung select has no "auto" row any
|
|
1011
|
+
* more, because "no rung pinned" is not a fourth level — it was the DEFAULT,
|
|
1012
|
+
* so a fresh window sent no effort at all and every model ran on whatever the
|
|
1013
|
+
* far end happened to do, which is what "the levels do nothing" was.
|
|
1014
|
+
*
|
|
1015
|
+
* A missing select (or one holding a stale `auto` persisted before this
|
|
1016
|
+
* change) resolves to `high`, the same default the engine and the CLI apply,
|
|
1017
|
+
* so an unreadable control can never silently mean "smallest".
|
|
873
1018
|
*/
|
|
874
1019
|
function effortFor() {
|
|
875
1020
|
const value = els.effortSelect && els.effortSelect.value;
|
|
876
|
-
return value
|
|
1021
|
+
return value === 'low' || value === 'medium' ? value : 'high';
|
|
877
1022
|
}
|
|
878
1023
|
|
|
879
1024
|
// ---------------------------------------------------------------- UI helpers
|
|
@@ -2121,7 +2266,7 @@ function memoryCard(e) {
|
|
|
2121
2266
|
if (rel) bits.push(rel);
|
|
2122
2267
|
else if (e.timestamp) bits.push(e.timestamp);
|
|
2123
2268
|
if (e.session) bits.push(`session ${e.session}`);
|
|
2124
|
-
if (e.tokenCount != null) bits.push(`${e.tokenCount}
|
|
2269
|
+
if (e.tokenCount != null) bits.push(`${e.tokenCount} tokens`);
|
|
2125
2270
|
meta.textContent = bits.join(' · ');
|
|
2126
2271
|
head.appendChild(meta);
|
|
2127
2272
|
li.appendChild(head);
|
|
@@ -3042,10 +3187,15 @@ async function spawnPath(card, spec) {
|
|
|
3042
3187
|
// A lane card is a dispatch the same way a turn is, and it was the
|
|
3043
3188
|
// un-costed half of the 3x story: it reported tokens with no charge beside
|
|
3044
3189
|
// them, so the extra calls were the least visible thing on the screen.
|
|
3190
|
+
// The card is also the place the wire's TOTAL was most misleading: a lane
|
|
3191
|
+
// re-sends the request plus a path hint, so its input half is nearly the
|
|
3192
|
+
// whole count and the card read as though the path had produced it all.
|
|
3193
|
+
// The figure is the output, like every other per-dispatch surface here.
|
|
3045
3194
|
const flow = turnAccounting(data && data.usage, spec.model, {
|
|
3046
3195
|
costUsd: data && typeof data.costUsd === 'number' ? data.costUsd : undefined,
|
|
3047
3196
|
});
|
|
3048
|
-
|
|
3197
|
+
const flowProduced = displayTokens(flow);
|
|
3198
|
+
if (flowProduced != null) bits.push(`${flowProduced} tokens`);
|
|
3049
3199
|
if (flow.cost != null) bits.push(fmtCost(flow.cost, flow.real));
|
|
3050
3200
|
// …and roll it into the session, which is the half that was missing. The
|
|
3051
3201
|
// card shows this ONE dispatch; a session total that skipped it would be
|
|
@@ -3164,6 +3314,19 @@ function setBusy(busy, { cancellable } = {}) {
|
|
|
3164
3314
|
dots.className = 'typing-dots';
|
|
3165
3315
|
for (let i = 0; i < 3; i++) dots.appendChild(document.createElement('span'));
|
|
3166
3316
|
pendingEl.querySelector('.body').appendChild(dots);
|
|
3317
|
+
// The live token figure, rendered by `renderRollMeter` — the same authority
|
|
3318
|
+
// that writes the topbar meter, from the same roll, so the two can never
|
|
3319
|
+
// disagree. A SIBLING of `.body`, not a child: `paintStream` assigns the
|
|
3320
|
+
// body's `textContent` on the first chunk, which deletes everything inside
|
|
3321
|
+
// it — the dots go with it deliberately, and this must not.
|
|
3322
|
+
//
|
|
3323
|
+
// Starts hidden and empty. A fresh turn has measured nothing yet, and the
|
|
3324
|
+
// `0 tokens` that would fill this line is the exact fabrication `fmtRoll`
|
|
3325
|
+
// refuses to print.
|
|
3326
|
+
const tokens = document.createElement('span');
|
|
3327
|
+
tokens.className = 'typing-tokens';
|
|
3328
|
+
tokens.hidden = true;
|
|
3329
|
+
pendingEl.appendChild(tokens);
|
|
3167
3330
|
if (cancellable) {
|
|
3168
3331
|
const cancelBtn = document.createElement('button');
|
|
3169
3332
|
cancelBtn.type = 'button';
|
|
@@ -4283,8 +4446,10 @@ async function send() {
|
|
|
4283
4446
|
// Sent whether or not the fan-out is ticked: the fan-out is enabled by the
|
|
4284
4447
|
// model id the pooled class sends, so a turn that never entered autonomous
|
|
4285
4448
|
// mode still ran pooled and had no way to say how big it should be — and a
|
|
4286
|
-
// DeepSeek reasoning model needs it to size its CoT.
|
|
4287
|
-
//
|
|
4449
|
+
// DeepSeek reasoning model needs it to size its CoT. It is always a rung now
|
|
4450
|
+
// (effortFor floors to `high`), never "state no opinion", because a pooled
|
|
4451
|
+
// call that states nothing is sized by the server's own default instead of
|
|
4452
|
+
// the level shown in the select.
|
|
4288
4453
|
const effort = effortFor();
|
|
4289
4454
|
const maxTokens = budgetFor(model, undefined, effort);
|
|
4290
4455
|
const workers = autonomous ? parseInt(els.autonomousWorkers.value, 10) || undefined : undefined;
|
|
@@ -4310,6 +4475,13 @@ async function send() {
|
|
|
4310
4475
|
threadMessages.push({ role: 'user', content: prompt });
|
|
4311
4476
|
|
|
4312
4477
|
setBusy(true, { cancellable: true });
|
|
4478
|
+
// The counter exists from the frame the turn does, not from its first chunk.
|
|
4479
|
+
// Both live surfaces this one mirrors have a figure on screen from the start
|
|
4480
|
+
// (`cli/src/chatflow.js`'s spinner tail reads `↓0 tokens` on its first render;
|
|
4481
|
+
// the reference engine's count climbs from t=0), and this bubble used to open
|
|
4482
|
+
// with an empty line and pop a number in a second later. Painting here also
|
|
4483
|
+
// means the reader never sees the row without the figure it will carry.
|
|
4484
|
+
if (pendingEl) renderRollMeter(sessionId, { prompt, reply: '', reasoning: '' });
|
|
4313
4485
|
// Sending is an explicit act, so it always returns the view to the tail.
|
|
4314
4486
|
// This is the single moment the auto-scroll overrides the reader's scroll.
|
|
4315
4487
|
stickToBottom({ force: true });
|
|
@@ -4328,7 +4500,16 @@ async function send() {
|
|
|
4328
4500
|
// construction (declared here, not at module scope), so a new turn starts its
|
|
4329
4501
|
// preview fresh — see `liveMeterDue` for why the meter is not recomputed on
|
|
4330
4502
|
// every frame.
|
|
4331
|
-
|
|
4503
|
+
//
|
|
4504
|
+
// Null, NOT 0: unset means "nothing painted for this turn yet", which
|
|
4505
|
+
// `liveMeterDue` reads as due on the first chunk. Seeded at 0 it read as
|
|
4506
|
+
// "measured an empty stream", so the turn's first paint waited out a full
|
|
4507
|
+
// step of the rule — a quarter kilobyte of text, ~64 tokens, seconds into a
|
|
4508
|
+
// slow turn — and the bubble sat with no figure at all and then jumped to it.
|
|
4509
|
+
// The CLI's spinner tail has no such dead zone (it prints `↓0 tokens` from
|
|
4510
|
+
// the first frame), so the two surfaces disagreed about when a count begins
|
|
4511
|
+
// existing.
|
|
4512
|
+
let meterChars = null;
|
|
4332
4513
|
|
|
4333
4514
|
// Text arrives in dozens of small chunks per second; painting each one is
|
|
4334
4515
|
// what made the window feel locked up. One paint per frame, off the latest
|
|
@@ -4420,7 +4601,7 @@ async function send() {
|
|
|
4420
4601
|
if (data && data.model) bits.push(`model: ${data.model}`);
|
|
4421
4602
|
else if (model) bits.push(`model: ${model}`);
|
|
4422
4603
|
bits.push(classLabel(cls));
|
|
4423
|
-
if (autonomous) bits.push(`autonomous (${effort
|
|
4604
|
+
if (autonomous) bits.push(`autonomous (${effort}, ${workers || 'auto'}w)`);
|
|
4424
4605
|
// The budget rung is worth showing even without the fan-out: on the pooled
|
|
4425
4606
|
// class it is what sized the call, and a user cannot tell a 16k turn from a
|
|
4426
4607
|
// 64k one by looking at the answer.
|
|
@@ -4431,26 +4612,42 @@ async function send() {
|
|
|
4431
4612
|
// alone left this surface with no comparable meter at all — the desktop
|
|
4432
4613
|
// and the CLI run the same engine, so a gap between them had to be
|
|
4433
4614
|
// measured on the same prompt, and one side was not measuring.
|
|
4615
|
+
//
|
|
4616
|
+
// The figure is the OUTPUT (`displayTokens`), not the wire's total: the
|
|
4617
|
+
// total is input + output, and on a cached turn the input half is the
|
|
4618
|
+
// larger one, so `tokens: 3,145` on a turn that wrote 145 tokens read as a
|
|
4619
|
+
// model emitting twenty times what it did. Same rule as the CLI's
|
|
4620
|
+
// `fmtsUsageSplit`, and the same one `fmtRoll` now uses below — the two
|
|
4621
|
+
// figures on this row are the same quantity for the same reason.
|
|
4434
4622
|
const turn = turnAccounting(data && data.usage, model, {
|
|
4435
4623
|
costUsd: data && typeof data.costUsd === 'number' ? data.costUsd : undefined,
|
|
4436
4624
|
});
|
|
4437
|
-
|
|
4625
|
+
const produced = displayTokens(turn);
|
|
4626
|
+
if (produced != null) bits.push(`tokens: ${produced}`);
|
|
4438
4627
|
// A turn the wire did not report on still gets a figure — the same text
|
|
4439
4628
|
// estimate that goes into the session total, marked `~` so an inferred
|
|
4440
4629
|
// count is never read as a reported one. Printing nothing here while the
|
|
4441
4630
|
// session total moved was the other half of "the counter looks dead".
|
|
4631
|
+
// Output only, like the reported figure beside it: `est.input` is the
|
|
4632
|
+
// prompt, and summing the two is the very reading this change removes.
|
|
4442
4633
|
else {
|
|
4443
4634
|
const est = estimatedBuckets(prompt, text, text === reasoningText ? '' : reasoningText);
|
|
4444
|
-
if (est) bits.push(`~${est.
|
|
4635
|
+
if (est && est.output > 0) bits.push(`~${est.output} tokens`);
|
|
4445
4636
|
}
|
|
4446
|
-
|
|
4637
|
+
// No money on a tier AEGIS bills no fee to, and none from a snapshot that
|
|
4638
|
+
// has not proven a fee is owed — see `feeBillable` in usage.js, which is the
|
|
4639
|
+
// CLI's exact rule. Not a `$0.0000` in its place: a zero states a price, and
|
|
4640
|
+
// this account has no proven price on this turn. The token figure above is
|
|
4641
|
+
// unaffected, so the row keeps its meter either way.
|
|
4642
|
+
const billable = feeBillable(entitlement());
|
|
4643
|
+
if (turn.cost != null && billable) bits.push(fmtCost(turn.cost, turn.real));
|
|
4447
4644
|
// …and on the byok class, whose money that figure is. The relay never
|
|
4448
4645
|
// returns a settled charge (nothing on that route emits one), so this is
|
|
4449
4646
|
// always the rate table's estimate of the caller's OWN provider bill — and
|
|
4450
4647
|
// AEGIS additionally collects a handling fee on the same turn, which this
|
|
4451
4648
|
// response cannot price. One bit, and the number stops reading as the whole
|
|
4452
4649
|
// cost of a BYOK turn.
|
|
4453
|
-
if (cls === 'byok' && turn.cost != null) {
|
|
4650
|
+
if (cls === 'byok' && turn.cost != null && billable) {
|
|
4454
4651
|
bits.push('your provider bill (est.) + AEGIS handling fee');
|
|
4455
4652
|
}
|
|
4456
4653
|
// …AND the running session total beside it, which is the number the CLI
|
|
@@ -4473,7 +4670,7 @@ async function send() {
|
|
|
4473
4670
|
// estimated twice if a turn ever collapses the two into one.
|
|
4474
4671
|
reasoning: text === reasoningText ? '' : reasoningText,
|
|
4475
4672
|
});
|
|
4476
|
-
const rollLine = fmtRoll(roll);
|
|
4673
|
+
const rollLine = fmtRoll(roll, { omitMoney: !billable });
|
|
4477
4674
|
if (rollLine) bits.push(`session: ${rollLine}`);
|
|
4478
4675
|
addMessage('assistant', text, bits.join(' · ') || undefined, sessionId, toolLog);
|
|
4479
4676
|
|
|
@@ -4548,10 +4745,14 @@ async function send() {
|
|
|
4548
4745
|
});
|
|
4549
4746
|
const stopBits = ['stopped by you'];
|
|
4550
4747
|
if (salvage.kind === 'reasoning-only') stopBits.push('reasoning only');
|
|
4551
|
-
|
|
4748
|
+
// The same figure and the same rule as the completed row above, so a
|
|
4749
|
+
// stopped turn and a finished one report one quantity. `displayTokens`
|
|
4750
|
+
// rather than `turn.tokens` — see the note there.
|
|
4751
|
+
const stopProduced = displayTokens(turn);
|
|
4752
|
+
if (stopProduced != null) stopBits.push(`tokens: ${stopProduced}`);
|
|
4552
4753
|
else {
|
|
4553
4754
|
const est = estimatedBuckets(prompt, text, salvageReasoning);
|
|
4554
|
-
if (est) stopBits.push(`~${est.
|
|
4755
|
+
if (est && est.output > 0) stopBits.push(`~${est.output} tokens`);
|
|
4555
4756
|
}
|
|
4556
4757
|
const stopRollLine = fmtRoll(roll);
|
|
4557
4758
|
if (stopRollLine) stopBits.push(`session: ${stopRollLine}`);
|
|
@@ -4650,17 +4851,21 @@ async function init() {
|
|
|
4650
4851
|
updateAutonomousControlsVisibility();
|
|
4651
4852
|
});
|
|
4652
4853
|
|
|
4653
|
-
// The one budget control: which rung sizes the call
|
|
4654
|
-
//
|
|
4655
|
-
//
|
|
4656
|
-
//
|
|
4657
|
-
//
|
|
4658
|
-
//
|
|
4659
|
-
//
|
|
4660
|
-
//
|
|
4854
|
+
// The one budget control: which rung sizes the call (low | medium | high).
|
|
4855
|
+
// Applies to EVERY class now — with the max-tokens dropdown gone there is
|
|
4856
|
+
// nothing else that sizes a call — so it is no longer stored under the
|
|
4857
|
+
// autonomous-mode namespace; an install predating the rename is read from
|
|
4858
|
+
// the old key once, and a stored rung this build's list no longer offers
|
|
4859
|
+
// falls through to the markup's default rather than assigning an option that
|
|
4860
|
+
// does not exist. That fall-through is load-bearing for the retired `auto`
|
|
4861
|
+
// value: a window that had `auto` persisted restores `high`, and the stale
|
|
4862
|
+
// string is rewritten so the next launch reads a rung that is really offered.
|
|
4661
4863
|
const savedEffort = localStorage.getItem(EFFORT_KEY) || localStorage.getItem(LEGACY_EFFORT_KEY);
|
|
4662
|
-
|
|
4864
|
+
const offered = Array.from(els.effortSelect.options).some((o) => o.value === savedEffort);
|
|
4865
|
+
if (savedEffort && offered) {
|
|
4663
4866
|
els.effortSelect.value = savedEffort;
|
|
4867
|
+
} else if (savedEffort) {
|
|
4868
|
+
localStorage.setItem(EFFORT_KEY, els.effortSelect.value);
|
|
4664
4869
|
}
|
|
4665
4870
|
els.effortSelect.addEventListener('change', () => {
|
|
4666
4871
|
localStorage.setItem(EFFORT_KEY, els.effortSelect.value);
|
package/renderer/index.html
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
<!-- The rolling session token total, counted the way the CLI counts it:
|
|
26
26
|
every finished turn folded into one running number rather than a
|
|
27
27
|
per-turn count that resets. Hidden until a turn has been accounted
|
|
28
|
-
for, so an untouched thread never shows a `0
|
|
28
|
+
for, so an untouched thread never shows a `0 tokens` it never measured. -->
|
|
29
29
|
<div class="session-meter" id="session-meter" hidden></div>
|
|
30
30
|
<div class="spacer"></div>
|
|
31
31
|
<button type="button" id="new-chat" class="ghost-btn">New chat</button>
|
|
@@ -138,8 +138,7 @@
|
|
|
138
138
|
<select id="effort-select">
|
|
139
139
|
<option value="low">low</option>
|
|
140
140
|
<option value="medium">medium</option>
|
|
141
|
-
<option value="high">high</option>
|
|
142
|
-
<option value="auto" selected>auto</option>
|
|
141
|
+
<option value="high" selected>high</option>
|
|
143
142
|
</select>
|
|
144
143
|
</div>
|
|
145
144
|
|
|
@@ -207,8 +207,23 @@ function isPlaceholderContent(content) {
|
|
|
207
207
|
);
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
-
/** The smallest stream growth, in characters, that earns a live-meter recompute.
|
|
211
|
-
|
|
210
|
+
/** The smallest stream growth, in characters, that earns a live-meter recompute.
|
|
211
|
+
*
|
|
212
|
+
* It was 256, and that number was the start-of-turn lag this rule exists to
|
|
213
|
+
* avoid: the counter's first paint required a quarter kilobyte of text — ~64
|
|
214
|
+
* tokens, several seconds into a slow turn — so the bubble opened with no
|
|
215
|
+
* figure at all and then jumped to it, while the CLI's own tail
|
|
216
|
+
* (`cli/src/chatflow.js`'s spinner, `↓${fmtTokens(state.streamed)}`) and the
|
|
217
|
+
* reference engine both have a count climbing from the first frame. 32 chars
|
|
218
|
+
* (~8 tokens) is a step the eye reads as continuous at the start of a reply,
|
|
219
|
+
* which is the whole point of a live count.
|
|
220
|
+
*
|
|
221
|
+
* The cost bound is NOT this number — it is the proportional term below. At
|
|
222
|
+
* 100k chars the step is 1/32 of the stream (3125), so lowering this floor
|
|
223
|
+
* leaves the long-reply recompute count unchanged; it only buys paints in the
|
|
224
|
+
* first ~1k characters, where each estimate is over a string too short to
|
|
225
|
+
* matter. */
|
|
226
|
+
const LIVE_METER_MIN_GROWTH = 32;
|
|
212
227
|
|
|
213
228
|
/** …and the fraction of the whole stream that must have arrived since the last
|
|
214
229
|
* one: a recompute comes due once the stream has grown by 1/this. */
|
|
@@ -241,6 +256,13 @@ const LIVE_METER_GROWTH_FRACTION = 32;
|
|
|
241
256
|
*/
|
|
242
257
|
function liveMeterDue(measured, streamChars) {
|
|
243
258
|
const now = Number(streamChars) || 0;
|
|
259
|
+
// Nothing has been painted yet this turn. The cursor is per-turn and starts
|
|
260
|
+
// UNSET (see app.js `let meterChars = null`), and an unset cursor must not be
|
|
261
|
+
// read as "measured an empty stream" — that made the first paint wait out the
|
|
262
|
+
// whole step below, which is the start-of-turn dead zone. The first text to
|
|
263
|
+
// arrive paints, so the figure is on screen from the first chunk rather than
|
|
264
|
+
// from the first 32nd of the reply.
|
|
265
|
+
if (measured === null || measured === undefined) return now > 0;
|
|
244
266
|
const grown = now - (Number(measured) || 0);
|
|
245
267
|
if (grown <= 0) return false;
|
|
246
268
|
const step = Math.max(LIVE_METER_MIN_GROWTH, Math.floor(now / LIVE_METER_GROWTH_FRACTION));
|
package/renderer/style.css
CHANGED
|
@@ -148,6 +148,15 @@ body {
|
|
|
148
148
|
display: none;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/* A total that is still climbing has to look different from one that has
|
|
152
|
+
stopped, or "is it still working?" is answered by watching for the absence
|
|
153
|
+
of change. Set by `renderRollMeter` from the same `live` flag that makes the
|
|
154
|
+
figure an estimate in the first place. */
|
|
155
|
+
.session-meter.live {
|
|
156
|
+
color: var(--accent);
|
|
157
|
+
border-color: var(--accent);
|
|
158
|
+
}
|
|
159
|
+
|
|
151
160
|
.spacer {
|
|
152
161
|
flex: 1;
|
|
153
162
|
}
|
|
@@ -876,21 +885,31 @@ body.memory-open {
|
|
|
876
885
|
text-align: center;
|
|
877
886
|
}
|
|
878
887
|
|
|
888
|
+
/* Dimmed by COLOR, not by `opacity`. The working indicator lives inside this
|
|
889
|
+
bubble, and `opacity` is inherited by everything painted under it — so the
|
|
890
|
+
0.6 that was meant to fade placeholder prose also faded the dots, which
|
|
891
|
+
already sat on `--text3` (#3a4a60). Two dims multiplied on the one element
|
|
892
|
+
whose entire job is to be seen: the composite was about #232c3a on a dark
|
|
893
|
+
surface, i.e. the spinner was effectively invisible (reported as "too
|
|
894
|
+
transparent"). A color dim cannot compound like that. */
|
|
879
895
|
.msg.pending {
|
|
880
|
-
|
|
896
|
+
color: var(--text2);
|
|
881
897
|
}
|
|
882
898
|
|
|
883
899
|
.typing-dots {
|
|
884
900
|
display: inline-flex;
|
|
885
901
|
align-items: center;
|
|
886
|
-
gap:
|
|
902
|
+
gap: 6px;
|
|
887
903
|
}
|
|
888
904
|
|
|
905
|
+
/* 8px and `--text2` at rest, not 6px and `--text3`: the pulse should read as
|
|
906
|
+
motion on top of a visible indicator, not as the indicator briefly
|
|
907
|
+
appearing out of nothing twice a second. */
|
|
889
908
|
.typing-dots span {
|
|
890
|
-
width:
|
|
891
|
-
height:
|
|
909
|
+
width: 8px;
|
|
910
|
+
height: 8px;
|
|
892
911
|
border-radius: 50%;
|
|
893
|
-
background: var(--
|
|
912
|
+
background: var(--text2);
|
|
894
913
|
animation: typing-glow 1.2s ease-in-out infinite;
|
|
895
914
|
}
|
|
896
915
|
|
|
@@ -910,15 +929,38 @@ body.memory-open {
|
|
|
910
929
|
0%,
|
|
911
930
|
60%,
|
|
912
931
|
100% {
|
|
913
|
-
background: var(--
|
|
932
|
+
background: var(--text2);
|
|
914
933
|
box-shadow: none;
|
|
934
|
+
transform: scale(1);
|
|
915
935
|
}
|
|
916
936
|
30% {
|
|
917
937
|
background: var(--accent);
|
|
918
|
-
box-shadow: 0 0
|
|
938
|
+
box-shadow: 0 0 9px 2px var(--accent);
|
|
939
|
+
transform: scale(1.25);
|
|
919
940
|
}
|
|
920
941
|
}
|
|
921
942
|
|
|
943
|
+
/* The live token figure, beside the spinner rather than only in the topbar.
|
|
944
|
+
Reported as "I don't see the tokens when AI is working": the rolling total
|
|
945
|
+
was already live, but it was 40ch away in the topbar and muted to `--text2`,
|
|
946
|
+
so it read as a settled readout. Here it is where the reader is already
|
|
947
|
+
looking, in the accent the pulse uses. */
|
|
948
|
+
.typing-tokens {
|
|
949
|
+
display: block;
|
|
950
|
+
margin-top: 8px;
|
|
951
|
+
font-family: var(--font-mono);
|
|
952
|
+
font-size: 11px;
|
|
953
|
+
color: var(--accent);
|
|
954
|
+
white-space: nowrap;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/* Same reason `.session-meter[hidden]` exists: the rule above sets a display,
|
|
958
|
+
which would otherwise win over the `hidden` attribute and leave an empty
|
|
959
|
+
accent line under every fresh turn. */
|
|
960
|
+
.typing-tokens[hidden] {
|
|
961
|
+
display: none;
|
|
962
|
+
}
|
|
963
|
+
|
|
922
964
|
.who {
|
|
923
965
|
font-size: 11px;
|
|
924
966
|
font-weight: 600;
|
package/renderer/usage.js
CHANGED
|
@@ -609,14 +609,23 @@ function usageCost(usage, model, opts = {}) {
|
|
|
609
609
|
* @param {object|null|undefined} usage the response's `usage` object
|
|
610
610
|
* @param {string} model the model id that answered
|
|
611
611
|
* @param {{costUsd?: number}} [opts] the server-settled charge, if any
|
|
612
|
-
* @returns {{tokens: number|null,
|
|
612
|
+
* @returns {{tokens: number|null, output: number|null, cost: number|null,
|
|
613
|
+
* real: boolean, estimated: boolean}}
|
|
613
614
|
* `real` is true only when `cost` is the settled charge. `cost` is
|
|
614
615
|
* `null` when nothing was reported and no model was named — an
|
|
615
616
|
* unpriced turn must render as nothing, never as $0.0000.
|
|
617
|
+
* `tokens` is the wire's TOTAL (input + output) and is what the ledger
|
|
618
|
+
* and the rolling fold need; `output` is the half a person reads, and
|
|
619
|
+
* it is what every per-turn surface shows (see `displayTokens`).
|
|
616
620
|
*/
|
|
617
621
|
function turnAccounting(usage, model, opts = {}) {
|
|
618
622
|
const tokens = usageTokens(usage);
|
|
619
623
|
const u = usage && typeof usage === 'object' ? usage : {};
|
|
624
|
+
// The half the turn PRODUCED. Kept beside the total rather than replacing it:
|
|
625
|
+
// `tokens` still has to fold the ledger, and a rebuild from stored buckets
|
|
626
|
+
// must agree with the live fold to the unit.
|
|
627
|
+
const output =
|
|
628
|
+
usage && typeof usage === 'object' ? usageBuckets(usage).output : null;
|
|
620
629
|
// The settled charge can arrive either on the response or folded into the
|
|
621
630
|
// usage object — the upstream engine does the latter
|
|
622
631
|
// (`{ ...result.usage, costUsd: result.costUsd }`), so both are accepted.
|
|
@@ -628,15 +637,23 @@ function turnAccounting(usage, model, opts = {}) {
|
|
|
628
637
|
// because a settled charge is a fact and a table match is a guess — and the
|
|
629
638
|
// pool's bill carries a margin and a prompt-cache discount this table
|
|
630
639
|
// cannot see, so the two must never be averaged or confused.
|
|
631
|
-
return {
|
|
640
|
+
return {
|
|
641
|
+
tokens, output, cost: settled, real: true, estimated: false, basis: 'settled', priced: true,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
if (tokens == null) {
|
|
645
|
+
return {
|
|
646
|
+
tokens, output, cost: null, real: false, estimated: false, basis: null, priced: false,
|
|
647
|
+
};
|
|
632
648
|
}
|
|
633
|
-
if (tokens == null) return { tokens, cost: null, real: false, estimated: false, basis: null, priced: false };
|
|
634
649
|
const b = costBreakdown(u, model, opts);
|
|
635
650
|
// A model the table cannot place still yields a number (ratesFor never
|
|
636
651
|
// returns nothing), so this is always an estimate — but `priced: false` says
|
|
637
652
|
// the figure is a placeholder rather than this model's rate, so the caller
|
|
638
653
|
// can label it instead of printing it as though it had been measured.
|
|
639
|
-
return {
|
|
654
|
+
return {
|
|
655
|
+
tokens, output, cost: b.cost, real: false, estimated: true, basis: b.basis, priced: b.priced,
|
|
656
|
+
};
|
|
640
657
|
}
|
|
641
658
|
|
|
642
659
|
/**
|
|
@@ -648,7 +665,7 @@ function turnAccounting(usage, model, opts = {}) {
|
|
|
648
665
|
* — `tokens`, `inputTokens`, `outputTokens`, `calls` — and what the user reads
|
|
649
666
|
* back is that RUNNING TOTAL. The status bar prints `state.tokens`
|
|
650
667
|
* (renderStatus), and `ctrl+t` prints the tallies in one line
|
|
651
|
-
* (`tokenSummary`: `12,400
|
|
668
|
+
* (`tokenSummary`: `12,400 tokens (10,100 in / 2,300 out) · 4 calls · €0.03`).
|
|
652
669
|
* The tallies are reproduced; that one abbreviation is not — `fmtRoll` explains
|
|
653
670
|
* why it drops the parenthetical rather than carrying the CLI's line verbatim.
|
|
654
671
|
*
|
|
@@ -869,36 +886,104 @@ function fmtTokens(n) {
|
|
|
869
886
|
.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
870
887
|
}
|
|
871
888
|
|
|
889
|
+
/**
|
|
890
|
+
* The token figure a person is SHOWN for a finished turn or a session roll.
|
|
891
|
+
*
|
|
892
|
+
* One authority, because there are two shapes that reach it and they had
|
|
893
|
+
* drifted apart: `turnAccounting` (a single dispatch, `{tokens, output}`) and
|
|
894
|
+
* `emptyRoll`/`rollTurn` (a session tally, same two fields). Every per-turn row
|
|
895
|
+
* and the topbar both route through here, so no surface can print a different
|
|
896
|
+
* quantity than another.
|
|
897
|
+
*
|
|
898
|
+
* What it returns is the OUTPUT — what the turn produced. The wire's total is
|
|
899
|
+
* input + output, and on any cached or long-context turn the input half
|
|
900
|
+
* dominates it: a 3,145-token call whose prompt was 3,000 tokens is a turn that
|
|
901
|
+
* wrote 145. Showing 3,145 beside a `session:` line that also reads 3,145 told
|
|
902
|
+
* the reader the model had emitted ten times what it did, and there is no way
|
|
903
|
+
* to tell from the row which of the two numbers is the prompt restated. The
|
|
904
|
+
* CLI settled this the same way (`fmtUsageSplit`, cli/src/format.js): the figure
|
|
905
|
+
* is the output, and the input half is not shown unlabelled beside it.
|
|
906
|
+
*
|
|
907
|
+
* It does NOT fall back to the total. Fall back to `tokens` and the input half
|
|
908
|
+
* is back on the screen with no label: a wire that priced a turn but did not
|
|
909
|
+
* itemise it would print `3,145` for a turn that wrote 145. Returns `null`
|
|
910
|
+
* rather than `0` in that case — `0 tokens` is a figure no provider sent,
|
|
911
|
+
* and this file refuses that fabrication everywhere else (`fmtRoll`).
|
|
912
|
+
*
|
|
913
|
+
* @param {{tokens?: number|null, output?: number|null}} [source]
|
|
914
|
+
* @returns {number|null}
|
|
915
|
+
*/
|
|
916
|
+
function displayTokens(source) {
|
|
917
|
+
if (!source || typeof source !== 'object') return null;
|
|
918
|
+
// The half the turn PRODUCED, from either shape this file is handed: an
|
|
919
|
+
// accounting or roll record (`output`), or a stored token bucket
|
|
920
|
+
// (`tokens: {output}` — what `rollMessages` reads back out of the shared
|
|
921
|
+
// store, and what a rebuilt session presents).
|
|
922
|
+
const nested = source.tokens;
|
|
923
|
+
const candidates = [source.output];
|
|
924
|
+
if (nested && typeof nested === 'object') {
|
|
925
|
+
candidates.push(nested.output, nested.output_tokens, nested.completion_tokens);
|
|
926
|
+
} else {
|
|
927
|
+
candidates.push(source.output_tokens, source.completion_tokens);
|
|
928
|
+
}
|
|
929
|
+
for (const c of candidates) {
|
|
930
|
+
if (typeof c === 'number' && isFinite(c) && c > 0) return c;
|
|
931
|
+
}
|
|
932
|
+
// The wire's TOTAL is deliberately NOT a fallback here. `tokens` is
|
|
933
|
+
// input + output, and on a cached turn the input half is the larger one, so
|
|
934
|
+
// falling back to it put back the exact reading this rule exists to remove:
|
|
935
|
+
// `tokens: 3,145` on a turn that wrote 145. This was the last surviving
|
|
936
|
+
// place the input half reached the screen — the split had been dropped, and
|
|
937
|
+
// the total it fell back to still carried it.
|
|
938
|
+
//
|
|
939
|
+
// Stated once more, because it is the whole point: a turn the wire priced but
|
|
940
|
+
// did not itemise has NO output figure, and inventing one from the total
|
|
941
|
+
// would be a number no provider sent. That turn's row states its own text
|
|
942
|
+
// estimate instead (`~N tokens`), which is at least a figure about what was
|
|
943
|
+
// written.
|
|
944
|
+
return null;
|
|
945
|
+
}
|
|
946
|
+
|
|
872
947
|
/**
|
|
873
948
|
* The rolling session total as one line — the desktop's counterpart of the
|
|
874
949
|
* CLI's `tokenSummary`. Empty string when nothing has been accounted for, so
|
|
875
950
|
* an untouched session adds no noise to a turn's meta line.
|
|
876
951
|
*
|
|
877
952
|
* @param {object} [roll]
|
|
878
|
-
* @returns {string} e.g. `
|
|
879
|
-
* the CLI's order, minus the input/output parenthetical (see below).
|
|
953
|
+
* @returns {string} e.g. `2,300 tokens · 4 calls · $0.0310` — the CLI's fields in
|
|
954
|
+
* the CLI's order, minus the input/output parenthetical (see below). The
|
|
955
|
+
* figure is the OUTPUT (see `displayTokens`).
|
|
880
956
|
*/
|
|
881
|
-
function fmtRoll(roll) {
|
|
957
|
+
function fmtRoll(roll, opts = {}) {
|
|
882
958
|
const r = roll || emptyRoll();
|
|
883
959
|
if (!r.turns && !r.calls) return '';
|
|
884
960
|
// The fields are `tokenSummary`'s (cli/src/app.js:1413) in its order — tokens,
|
|
885
961
|
// calls, money — with one dropped: the `(10,100 in / 2,300 out)` split. `in`
|
|
886
962
|
// and `out` are a terminal status-line shorthand, legible to someone already
|
|
887
963
|
// reading that status line and to nobody else, which is what an unlabelled
|
|
888
|
-
// abbreviation in a GUI topbar turns into.
|
|
889
|
-
//
|
|
890
|
-
//
|
|
891
|
-
//
|
|
892
|
-
//
|
|
964
|
+
// abbreviation in a GUI topbar turns into.
|
|
965
|
+
//
|
|
966
|
+
// …and the figure that remains is the OUTPUT, not the wire's total. This line
|
|
967
|
+
// used to print `r.tokens`, which is input + output: on a cached turn the
|
|
968
|
+
// prompt restated is the larger half, so a session that wrote 2,300 tokens on
|
|
969
|
+
// a 10,100-token prompt read `12,400 tokens` and looked like a model emitting
|
|
970
|
+
// five times what it did. The parenthetical was not the only place the input
|
|
971
|
+
// half showed up — it was in the total too, and dropping the split alone left
|
|
972
|
+
// the reader comparing two numbers that were both mostly their own prompt.
|
|
973
|
+
// `displayTokens` holds the rule, the same one the CLI's `fmtUsageSplit` uses.
|
|
974
|
+
// `r.input` is still folded onto the roll (see `rollTurn`) for any surface
|
|
975
|
+
// that wants to show the split with real labels.
|
|
893
976
|
const bits = [];
|
|
894
977
|
// The token half only when something was actually counted. `rollTurn` counts
|
|
895
978
|
// turns and calls BEFORE it looks at the token count, so a dispatch that
|
|
896
|
-
// reported nothing still reaches here — and printing
|
|
897
|
-
//
|
|
898
|
-
// counter that does not move.
|
|
899
|
-
//
|
|
900
|
-
|
|
901
|
-
|
|
979
|
+
// reported nothing still reaches here — and printing an empty tally would put
|
|
980
|
+
// `0 tokens` on the topbar, a figure the meter never took, which reads as a
|
|
981
|
+
// counter that does not move. `displayTokens` returns null rather than 0 for
|
|
982
|
+
// exactly that case, and falls back to the total only when no split arrived.
|
|
983
|
+
// The turn count is still stated, because that much is true.
|
|
984
|
+
const produced = displayTokens(r);
|
|
985
|
+
if (produced != null) {
|
|
986
|
+
bits.push(`${fmtTokens(produced)} tokens`);
|
|
902
987
|
}
|
|
903
988
|
bits.push(`${r.calls} call${r.calls === 1 ? '' : 's'}`);
|
|
904
989
|
// Money is where this line departs from `tokenSummary`, deliberately: that
|
|
@@ -906,7 +991,10 @@ function fmtRoll(roll) {
|
|
|
906
991
|
// a settled charge and a local estimate apart so a `~`-estimate can never be
|
|
907
992
|
// read as part of the bill. Desktop's pre-existing fmtCost renders both, and
|
|
908
993
|
// its `$` convention is left exactly as it was.
|
|
909
|
-
|
|
994
|
+
// Money is omitted entirely on a tier AEGIS does not bill a fee to
|
|
995
|
+
// (`opts.omitMoney` — see `feeWaived`). Not zeroed, not marked `~`: a `$0.0000`
|
|
996
|
+
// states a price, and this account has no price on this turn.
|
|
997
|
+
if (!opts.omitMoney && (r.cost > 0 || r.estimate > 0)) {
|
|
910
998
|
const money = [];
|
|
911
999
|
if (r.cost > 0) money.push(fmtCost(r.cost, true));
|
|
912
1000
|
if (r.estimate > 0) money.push(fmtCost(r.estimate, false));
|
|
@@ -935,11 +1023,108 @@ function fmtRoll(roll) {
|
|
|
935
1023
|
*/
|
|
936
1024
|
function fmtCost(cost, real) {
|
|
937
1025
|
if (typeof cost !== 'number' || !isFinite(cost)) return '';
|
|
1026
|
+
// The `$` is not decoration: its loss was a regression, not a decision.
|
|
1027
|
+
// `4d5d1e9` introduced this function with it (`~$18.0000`); `6d48470` — a
|
|
1028
|
+
// commit about the token TOTAL, unrelated to money — dropped it as a stray
|
|
1029
|
+
// hunk. Every desktop turn row then painted a bare `18.0000`, a number with no
|
|
1030
|
+
// currency on it, where the CLI paints `€0.03` for the same figure.
|
|
938
1031
|
return `${real ? '' : '~'}$${cost.toFixed(4)}`;
|
|
939
1032
|
}
|
|
940
1033
|
|
|
1034
|
+
/**
|
|
1035
|
+
* True when this account's tier means AEGIS bills no per-turn fee, so no money
|
|
1036
|
+
* figure belongs on a turn row or a session roll.
|
|
1037
|
+
*
|
|
1038
|
+
* The fee in question is the BYOK handling fee — the only charge AEGIS adds on
|
|
1039
|
+
* top of a caller's own provider bill — and aegis1 waives it on Unlimited:
|
|
1040
|
+
* `lib/local/entitlement.js` states the rule in as many words ("the throughput
|
|
1041
|
+
* allowance and the BYOK fee waiver are `is_unlimited()`-gated"), and the tier
|
|
1042
|
+
* a welcome trial grants IS Unlimited (a 30-day `unlimited` row in
|
|
1043
|
+
* `subscriptions`, which `register_device` writes in the same request that mints
|
|
1044
|
+
* the key). So a trial account resolves here exactly like a paying one, which is
|
|
1045
|
+
* what "trial unlimited" means — the trial is not a separate tier with its own
|
|
1046
|
+
* money rule, it is Unlimited for 30 days.
|
|
1047
|
+
*
|
|
1048
|
+
* Matching is on the resolved tier rather than `pro`: `pro` is "the PLAN is paid
|
|
1049
|
+
* for" and is also true of the €9 tier's aliases, while the waiver is gated on
|
|
1050
|
+
* `unlimited` specifically. A funded bank on a free plan is not a tier at all
|
|
1051
|
+
* (`entitlement.js` refuses to name one) and is therefore NOT waived — that
|
|
1052
|
+
* account pays the fee from the bank, and hiding the number would leave it with
|
|
1053
|
+
* no meter for the only money it spends.
|
|
1054
|
+
*
|
|
1055
|
+
* @param {{tier?: string|null, plan?: string|null, planRaw?: string|null}} [entitlement]
|
|
1056
|
+
* @returns {boolean}
|
|
1057
|
+
*/
|
|
1058
|
+
function feeWaived(entitlement) {
|
|
1059
|
+
if (!entitlement || typeof entitlement !== 'object') return false;
|
|
1060
|
+
const named = [
|
|
1061
|
+
entitlement.tier,
|
|
1062
|
+
entitlement.plan,
|
|
1063
|
+
entitlement.planRaw,
|
|
1064
|
+
entitlement.subscription,
|
|
1065
|
+
entitlement.subscriptionTier,
|
|
1066
|
+
entitlement.product,
|
|
1067
|
+
// The server's own two fields, as `register_device` writes them: a welcome
|
|
1068
|
+
// trial is a 30-day `unlimited` row in `subscriptions`, and `item`/`sku` is
|
|
1069
|
+
// that row's product name. Naming them here is what keeps a trial account
|
|
1070
|
+
// from resolving to "no tier" and being billed a fee it does not owe.
|
|
1071
|
+
entitlement.item,
|
|
1072
|
+
entitlement.sku,
|
|
1073
|
+
]
|
|
1074
|
+
.filter((v) => typeof v === 'string')
|
|
1075
|
+
.map((v) => v.trim().toLowerCase());
|
|
1076
|
+
// An explicit boolean outranks every spelling below: aegis1's `is_unlimited()`
|
|
1077
|
+
// verdict is the fact, and the strings are only what it is written as on the
|
|
1078
|
+
// wire. A probe that answers with the bit and not the tier is still an answer.
|
|
1079
|
+
if (entitlement.unlimited === true) return true;
|
|
1080
|
+
if (named.some((v) => v === 'unlimited' || v === 'trial')) return true;
|
|
1081
|
+
// A tier string that merely CONTAINS the word — `unlimited_trial`,
|
|
1082
|
+
// `plan.unlimited`, `Unlimited (trial)` — so a server that decorates the name
|
|
1083
|
+
// cannot put the fee back on an account whose tier is Unlimited.
|
|
1084
|
+
return named.some((v) => /(^|[^a-z])unlimited([^a-z]|$)/.test(v));
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Whether a money figure belongs on this account's turn row or session roll.
|
|
1089
|
+
*
|
|
1090
|
+
* The CLI's exact rule (`cli/src/format.js` → `feeBillable`), and it is the same
|
|
1091
|
+
* rule on purpose — the two hosts render the same turn from the same engine, so
|
|
1092
|
+
* a row that shows a fee on one and waives it on the other is a disagreement
|
|
1093
|
+
* about what the user owes.
|
|
1094
|
+
*
|
|
1095
|
+
* A price is printed UNLESS AEGIS either waives it or has not proven it owed:
|
|
1096
|
+
*
|
|
1097
|
+
* - a tier AEGIS waives (`feeWaived`) → no price. Unlimited, paid or on the
|
|
1098
|
+
* 30-day welcome trial, has no per-turn charge and no bank draw;
|
|
1099
|
+
* - a snapshot asserting a plan it cannot prove (`degraded`) → no price. The
|
|
1100
|
+
* entitlement module STARTS at exactly that: `makeFreeSnapshot()` reads
|
|
1101
|
+
* `plan: 'free'`, `tier: null`, `source: 'unresolved'`, `degraded: true`,
|
|
1102
|
+
* which says "no verdict yet", not "this account is free". Reading the
|
|
1103
|
+
* placeholder as a verdict is what put `~$0.0005` and an "AEGIS handling fee"
|
|
1104
|
+
* line on the turns of trial accounts whose probe had not answered — a fee
|
|
1105
|
+
* charged in writing to the one account that does not pay it;
|
|
1106
|
+
* - everything else → the price stands, including when no snapshot exists at
|
|
1107
|
+
* all. A cost figure is a measurement of the turn, and dropping a real number
|
|
1108
|
+
* because no tier was published would leave a self-hosted or BYO-key run with
|
|
1109
|
+
* no meter for money it actually spent.
|
|
1110
|
+
*
|
|
1111
|
+
* The token figure is never gated by this, on any branch: what a turn produced
|
|
1112
|
+
* is not a price, and hiding it would leave an Unlimited account with no meter.
|
|
1113
|
+
*
|
|
1114
|
+
* @param {{tier?: string|null, plan?: string|null, planRaw?: string|null,
|
|
1115
|
+
* degraded?: boolean, source?: string|null, unlimited?: boolean}} [entitlement]
|
|
1116
|
+
* @returns {boolean}
|
|
1117
|
+
*/
|
|
1118
|
+
function feeBillable(entitlement) {
|
|
1119
|
+
if (!entitlement || typeof entitlement !== 'object') return true;
|
|
1120
|
+
if (feeWaived(entitlement)) return false;
|
|
1121
|
+
return entitlement.degraded !== true;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
941
1124
|
if (typeof module !== 'undefined' && module.exports) {
|
|
942
1125
|
module.exports = {
|
|
1126
|
+
feeWaived,
|
|
1127
|
+
feeBillable,
|
|
943
1128
|
RATES,
|
|
944
1129
|
RATE_BASIS,
|
|
945
1130
|
UNPRICED_BY_DESIGN,
|
|
@@ -951,6 +1136,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
|
|
951
1136
|
usageCost,
|
|
952
1137
|
costBreakdown,
|
|
953
1138
|
turnAccounting,
|
|
1139
|
+
displayTokens,
|
|
954
1140
|
estimateTokens,
|
|
955
1141
|
estimatedBuckets,
|
|
956
1142
|
emptyRoll,
|