From 9e491f8b773a4a76ddf8603b151b57c1abb53630 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Thu, 23 Jul 2026 20:17:14 -0400 Subject: [PATCH 1/4] feat(ai): show run cost and balance in credits in the response bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The response bar read "Opus 4.8 · $0.46 · $12.79 left"; it now reads "Opus 4.8 · 46 credits · 1,279 left". $1 = 100 credits — display only, and it matches the account dashboard exactly because the gateway already sends the same RETAIL micro-$ figures the website renders. money() is replaced by two helpers off one conversion (MICROS_PER_CREDIT), so the editor and the website can never disagree about the same balance: - creditBalance() — whole credits, the number people plan around. - creditCost() — keeps one decimal below 10, because a cheap-model turn costs ~0.4 credits and rounding it to "0" would read as free. The old money() had the same problem and solved it with "<$0.01". Verified against the real values behind the shipped UI: $12.79 → "1,279", $0.46 → "46", a $0.003577 gpt-oss turn → "0.4", $0.5117 Opus turn → "51", $100 → "10,000", and null/zero → "0". Gate: 22 suites, 0 failures. Co-Authored-By: Claude Opus 4.8 --- extensions/levelcode-ai/media/chat.html | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/extensions/levelcode-ai/media/chat.html b/extensions/levelcode-ai/media/chat.html index f5154bc..eeb08d2 100644 --- a/extensions/levelcode-ai/media/chat.html +++ b/extensions/levelcode-ai/media/chat.html @@ -2421,10 +2421,19 @@ } // [LevelCode] Retail micro-$ → a human figure. Sub-cent turns are common once prompt caching kicks // in, and "$0.00" would read as free/broken — so floor them at "<$0.01" instead of rounding away. - function money(micros){ - const d = Number(micros) / 1e6; - if (!isFinite(d) || d <= 0) { return '$0.00'; } - return d < 0.01 ? '<$0.01' : '$' + d.toFixed(2); + // Credits are the in-product spending unit: $1 = 100 credits, so 1 credit = 10_000 retail micro-$. + // The gateway sends RETAIL micro-$ (Levelcode.retail_micros), the same figures the account dashboard + // renders — so converting here with the same rule keeps the editor and the website in agreement. + const MICROS_PER_CREDIT = 10000; + function toCredits(micros){ const c = Number(micros) / MICROS_PER_CREDIT; return isFinite(c) ? c : 0; } + // A balance reads as a whole credit count. A single run, though, can cost well under one credit (a + // cheap-model turn is ~0.4), and rounding that to "0" would read as free — so small costs keep one + // decimal until they're big enough not to need it. + function creditBalance(micros){ return Math.round(toCredits(micros)).toLocaleString(); } + function creditCost(micros){ + const c = toCredits(micros); + if (c <= 0) { return '0'; } + return c < 10 ? String(+c.toFixed(1)) : Math.round(c).toLocaleString(); } // Response action bar: (Retry) (Copy) (Helpful) (Unhelpful) … model · $cost · $left. @@ -2447,8 +2456,8 @@ + '' + esc(modelLabel()) + '' // Gateway runs report real money (retail micro-$): what this run cost + what's left. BYOK runs // report neither, so the bar stays exactly as it was. - + (costMicros != null ? '· ' + esc(money(costMicros)) + '' : '') - + (credits != null ? '· ' + esc(money(credits)) + ' left' : ''); + + (costMicros != null ? '· ' + esc(creditCost(costMicros)) + ' credits' : '') + + (credits != null ? '· ' + esc(creditBalance(credits)) + ' left' : ''); bar.append(actions, meta); log.appendChild(bar); scrollIfStuck(); From 7d6c711e8e48be20b6397c8525fae0aaba0a069d Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Thu, 23 Jul 2026 21:02:22 -0400 Subject: [PATCH 2/4] fix(ai): keep the per-turn decimal in the response bar (mirrors onetime PR #86) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit creditCost() used the same unary-+ pattern the dashboard did, so it carried the same bug: 7.0 rendered as "7", and anything under 0.05 credits collapsed to "0" — a run that cost something reading as free. Fixed identically to AccountPage.tsx's rate(), with an explicit "<0.1" floor, so the editor and the website format a cost the same way rather than drifting. Verified: 23 suites, 0 failures; $0.0002 -> "<0.1", $0.70 -> "7.0", $0.46 -> "46". Co-Authored-By: Claude Opus 4.8 --- extensions/levelcode-ai/media/chat.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/extensions/levelcode-ai/media/chat.html b/extensions/levelcode-ai/media/chat.html index eeb08d2..7a360ba 100644 --- a/extensions/levelcode-ai/media/chat.html +++ b/extensions/levelcode-ai/media/chat.html @@ -2433,7 +2433,11 @@ function creditCost(micros){ const c = toCredits(micros); if (c <= 0) { return '0'; } - return c < 10 ? String(+c.toFixed(1)) : Math.round(c).toLocaleString(); + if (c >= 10) { return Math.round(c).toLocaleString(); } + // Fixed string, not +String(...): the unary + dropped the decimal (7.0 -> "7") and collapsed + // anything under 0.05 to "0" — a run that cost something reading as free. Same fix as the + // dashboard's rate(), kept identical so the two surfaces render a cost the same way. + return c < 0.05 ? '<0.1' : c.toFixed(1); } // Response action bar: (Retry) (Copy) (Helpful) (Unhelpful) … model · $cost · $left. From 91bf0c5ac1b7af8939ea4413f883fa8c6e1ad2ed Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Thu, 23 Jul 2026 21:05:37 -0400 Subject: [PATCH 3/4] fix(ai): clamp a negative balance, refresh stale comments, test the formatters (PR #34 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three review points were real. 1. creditBalance() could render a negative ("-2 left"). An overage pushes the remaining balance below zero in the ledger, and the dollar formatter this replaced clamped at $0.00 — that clamp was lost in the port. Restored. (The same comment also flagged creditCost() rounding sub-0.05 costs to "0"; that half was already fixed in 7d6c711, which added the "<0.1" floor.) 2. The comment block above the helpers still described the deleted money() behaviour ("$0.00" / "<$0.01"), and the response-bar comment still read "model · $cost · $left". Both now describe credits. 3. The helpers had no tests, in a repo that already extracts and tests shipped chat.html functions. Added creditFormat.test.js on the narrativeUi/shHighlight pattern — it EXTRACTS the real functions from chat.html rather than copying them, and asserts MICROS_PER_CREDIT is still 10000 so the editor can't drift from the website's conversion. To make extraction work, toCredits and creditBalance became multi-line (the convention slices to "\n }"). Verified: 23 suites, 0 failures; creditFormat is 7 cases. Both fixes are mutation-checked — removing the clamp fails, and restoring the unary + fails. The suite also caught an arithmetic error in my own first draft ($0.70 is 70 credits, not 7.0), which is a fair advertisement for the reviewer's point. Co-Authored-By: Claude Opus 4.8 --- extensions/levelcode-ai/media/chat.html | 34 +++--- .../levelcode-ai/test/creditFormat.test.js | 104 ++++++++++++++++++ 2 files changed, 124 insertions(+), 14 deletions(-) create mode 100644 extensions/levelcode-ai/test/creditFormat.test.js diff --git a/extensions/levelcode-ai/media/chat.html b/extensions/levelcode-ai/media/chat.html index 7a360ba..e7d48f6 100644 --- a/extensions/levelcode-ai/media/chat.html +++ b/extensions/levelcode-ai/media/chat.html @@ -2419,28 +2419,34 @@ function modelLabel(){ try { const t = (document.getElementById('model').textContent || 'Auto').replace('▾', '').trim(); return t || 'Auto'; } catch(e){ return 'Auto'; } } - // [LevelCode] Retail micro-$ → a human figure. Sub-cent turns are common once prompt caching kicks - // in, and "$0.00" would read as free/broken — so floor them at "<$0.01" instead of rounding away. - // Credits are the in-product spending unit: $1 = 100 credits, so 1 credit = 10_000 retail micro-$. - // The gateway sends RETAIL micro-$ (Levelcode.retail_micros), the same figures the account dashboard - // renders — so converting here with the same rule keeps the editor and the website in agreement. + // [LevelCode] Credits are the in-product spending unit: $1 = 100 credits, so 1 credit = 10_000 + // retail micro-$. The gateway sends RETAIL micro-$ (Levelcode.retail_micros) — the same figures the + // account dashboard renders — so converting here with the same rule keeps the editor and the website + // showing one number. Pinned by test/creditFormat.test.js, which extracts these very functions. const MICROS_PER_CREDIT = 10000; - function toCredits(micros){ const c = Number(micros) / MICROS_PER_CREDIT; return isFinite(c) ? c : 0; } - // A balance reads as a whole credit count. A single run, though, can cost well under one credit (a - // cheap-model turn is ~0.4), and rounding that to "0" would read as free — so small costs keep one - // decimal until they're big enough not to need it. - function creditBalance(micros){ return Math.round(toCredits(micros)).toLocaleString(); } + function toCredits(micros){ + const c = Number(micros) / MICROS_PER_CREDIT; + return isFinite(c) ? c : 0; + } + // A balance reads as a whole credit count. Clamped at zero: an overage can push the remaining + // balance negative, and "-2 left" is a worse thing to put in front of someone than "0 left" (the + // dollar formatter this replaced clamped the same way). + function creditBalance(micros){ + const c = toCredits(micros); + return (c > 0 ? Math.round(c) : 0).toLocaleString(); + } + // A single run can cost well under one credit (a cheap-model turn is ~0.4), so small costs keep one + // decimal, and anything under 0.05 floors to "<0.1" rather than rounding to "0" — a run that cost + // something must never read as free. Returns the FIXED string: wrapping it in a unary + to drop + // trailing zeros silently defeated both of those rules. Identical to the dashboard's rate(). function creditCost(micros){ const c = toCredits(micros); if (c <= 0) { return '0'; } if (c >= 10) { return Math.round(c).toLocaleString(); } - // Fixed string, not +String(...): the unary + dropped the decimal (7.0 -> "7") and collapsed - // anything under 0.05 to "0" — a run that cost something reading as free. Same fix as the - // dashboard's rate(), kept identical so the two surfaces render a cost the same way. return c < 0.05 ? '<0.1' : c.toFixed(1); } - // Response action bar: (Retry) (Copy) (Helpful) (Unhelpful) … model · $cost · $left. + // Response action bar: (Retry) (Copy) (Helpful) (Unhelpful) … model · cost · balance, in credits. function addAgentDone(reason, edits, credits, maxSteps, costMicros){ closeGroup(); // the run is over — finalize any open activity group const bar = document.createElement('div'); bar.className = 'agentbar'; diff --git a/extensions/levelcode-ai/test/creditFormat.test.js b/extensions/levelcode-ai/test/creditFormat.test.js new file mode 100644 index 0000000..7786d1c --- /dev/null +++ b/extensions/levelcode-ai/test/creditFormat.test.js @@ -0,0 +1,104 @@ +/*--------------------------------------------------------------------------------------------- + * Unit tests for the response bar's credit formatting — run: node test/creditFormat.test.js + * + * Like narrativeUi.test.js and shHighlight.test.js, the functions are EXTRACTED from the shipped + * chat.html, so these exercise the real code rather than a copy that can drift from it. + * + * Two directions are load-bearing, and both are ways of lying about money: + * 1. A run that cost something must never render as "0" — sub-credit turns are the common case on + * cheap models, so they floor rather than round away. + * 2. A balance must never render negative. An overage can push it below zero, and "-2 left" is a + * worse thing to show someone than "0 left" (the dollar formatter this replaced clamped too). + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const html = fs.readFileSync(path.join(__dirname, '..', 'media', 'chat.html'), 'utf8'); + +// Same slicing convention as narrativeUi.test.js: functions sit at 2 spaces and close with " }". +function extract(name) { + const start = html.indexOf('function ' + name + '('); + assert.ok(start >= 0, 'chat.html no longer defines ' + name + '()'); + const end = html.indexOf('\n }', start); + assert.ok(end >= 0, 'no closing brace found for ' + name + '()'); + return html.slice(start, end + 4); +} + +// MICROS_PER_CREDIT is a const in the same scope — pull it from the source so the test can never +// disagree with the shipped conversion rate. +const rateMatch = /const MICROS_PER_CREDIT = (\d+);/.exec(html); +assert.ok(rateMatch, 'chat.html no longer defines MICROS_PER_CREDIT'); +assert.strictEqual(rateMatch[1], '10000', '1 credit must stay $0.01 — the website converts identically'); + +const sandbox = /** @type {any} */ ({}); +new Function( + 'const MICROS_PER_CREDIT = ' + rateMatch[1] + ';\n' + + extract('toCredits') + '\n' + extract('creditBalance') + '\n' + extract('creditCost') + '\n' + + 'this.toCredits = toCredits; this.creditBalance = creditBalance; this.creditCost = creditCost;' +).call(sandbox); + +const { toCredits, creditBalance, creditCost } = sandbox; + +let n = 0; +function test(name, fn) { fn(); n++; console.log(' ok - ' + name); } + +const usd = (d) => Math.round(d * 1e6); // dollars → retail micro-$, the unit the gateway sends + +// ---- 1. a cost must never read as free -------------------------------------------------------- + +test('COST: a sub-credit run floors to "<0.1" instead of rounding to zero', () => { + // The cheap-model case, and the whole reason this is not a plain round(): a gpt-oss turn is ~0.4 + // credits and a very cheap one is far less. "0" would tell the user the run was free. + assert.strictEqual(creditCost(usd(0.0002)), '<0.1'); + assert.strictEqual(creditCost(usd(0.0004)), '<0.1'); + assert.strictEqual(creditCost(usd(0.0036)), '0.4'); // the real gpt-oss per-turn figure +}); + +test('COST: keeps one decimal below 10, whole credits above', () => { + assert.strictEqual(creditCost(usd(0.07)), '7.0', 'the trailing .0 must survive'); + assert.strictEqual(creditCost(usd(0.0767)), '7.7'); + assert.strictEqual(creditCost(usd(0.46)), '46'); + assert.strictEqual(creditCost(usd(0.5117)), '51'); +}); + +test('COST: a genuinely zero cost is "0", not "<0.1"', () => { + assert.strictEqual(creditCost(0), '0'); + assert.strictEqual(creditCost(-1), '0', 'a negative cost is nonsense — show nothing owed'); +}); + +// ---- 2. a balance must never read negative ---------------------------------------------------- + +test('BALANCE: whole credits, with separators', () => { + assert.strictEqual(creditBalance(usd(12.79)), '1,279'); // the figure the dashboard shows + assert.strictEqual(creditBalance(usd(100)), '10,000'); // an Ultra month + assert.strictEqual(creditBalance(usd(0.004)), '0'); +}); + +test('BALANCE: an overage clamps to 0 rather than showing a negative', () => { + // Going over budget makes remaining negative in the ledger. "-2 left" is worse than "0 left". + assert.strictEqual(creditBalance(usd(-0.02)), '0'); + assert.strictEqual(creditBalance(usd(-5)), '0'); + assert.strictEqual(creditBalance(-1), '0'); +}); + +// ---- 3. junk in, something sane out ------------------------------------------------------------ + +test('ROBUST: nullish and non-numeric input never render NaN', () => { + for (const junk of [null, undefined, '', 'abc', {}, []]) { + assert.strictEqual(creditBalance(junk), '0', 'balance from ' + JSON.stringify(junk)); + assert.strictEqual(creditCost(junk), '0', 'cost from ' + JSON.stringify(junk)); + } + assert.strictEqual(toCredits(Infinity), 0, 'a non-finite figure must collapse to 0, not Infinity'); +}); + +test('CONVERSION: $1 is 100 credits, in both directions of the bar', () => { + assert.strictEqual(toCredits(usd(1)), 100); + assert.strictEqual(creditBalance(usd(1)), '100'); + assert.strictEqual(creditCost(usd(1)), '100'); +}); + +console.log('\ncreditFormat (chat.html): ' + n + ' tests passed.'); From 2c1e0d7a481a545259cee1cb7f87c9ba1a624ed0 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Thu, 23 Jul 2026 21:11:24 -0400 Subject: [PATCH 4/4] fix(ai): pin the credit grouping locale (PR #34 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit creditBalance/creditCost called toLocaleString() with no locale, so digit grouping followed the machine's ambient locale. Verified: under de-DE the same balance renders "1.279" where en-US gives "1,279". Two consequences, both bad. The editor and the account dashboard would disagree about the formatting of the same number, which undercuts the whole point of converting micro-$ the same way in both places. And the new unit tests assert comma grouping, so they were quietly dependent on the CI runner's locale — they would have failed on a non-US machine. Now pinned to en-US, matching the dashboard and matching the fixed US-style money strings the bar rendered before credits. The test pulls CREDIT_LOCALE from source and additionally fails on ANY bare toLocaleString() in chat.html, so a future edit cannot reintroduce the ambient-locale dependency. Verified: 23 suites, 0 failures; creditFormat is 8 cases. Mutation-checked — reverting to a bare toLocaleString() fails the suite. Note: the account dashboard (onetime AccountPage.tsx) has the same bare call. That PR is already merged, so it needs its own follow-up rather than a change here. Co-Authored-By: Claude Opus 4.8 --- extensions/levelcode-ai/media/chat.html | 9 +++++++-- .../levelcode-ai/test/creditFormat.test.js | 17 ++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/extensions/levelcode-ai/media/chat.html b/extensions/levelcode-ai/media/chat.html index e7d48f6..ed6a948 100644 --- a/extensions/levelcode-ai/media/chat.html +++ b/extensions/levelcode-ai/media/chat.html @@ -2424,6 +2424,11 @@ // account dashboard renders — so converting here with the same rule keeps the editor and the website // showing one number. Pinned by test/creditFormat.test.js, which extracts these very functions. const MICROS_PER_CREDIT = 10000; + // Group digits with a FIXED locale, not the ambient one. The bar it replaced rendered plain US-style + // money strings, the account dashboard formats the same figure the same way, and an ambient locale + // would make one balance render differently per machine ("1,279" vs "1.279" vs "1 279") — including + // on a CI runner, which would make these tests environment-dependent. + const CREDIT_LOCALE = 'en-US'; function toCredits(micros){ const c = Number(micros) / MICROS_PER_CREDIT; return isFinite(c) ? c : 0; @@ -2433,7 +2438,7 @@ // dollar formatter this replaced clamped the same way). function creditBalance(micros){ const c = toCredits(micros); - return (c > 0 ? Math.round(c) : 0).toLocaleString(); + return (c > 0 ? Math.round(c) : 0).toLocaleString(CREDIT_LOCALE); } // A single run can cost well under one credit (a cheap-model turn is ~0.4), so small costs keep one // decimal, and anything under 0.05 floors to "<0.1" rather than rounding to "0" — a run that cost @@ -2442,7 +2447,7 @@ function creditCost(micros){ const c = toCredits(micros); if (c <= 0) { return '0'; } - if (c >= 10) { return Math.round(c).toLocaleString(); } + if (c >= 10) { return Math.round(c).toLocaleString(CREDIT_LOCALE); } return c < 0.05 ? '<0.1' : c.toFixed(1); } diff --git a/extensions/levelcode-ai/test/creditFormat.test.js b/extensions/levelcode-ai/test/creditFormat.test.js index 7786d1c..1db5668 100644 --- a/extensions/levelcode-ai/test/creditFormat.test.js +++ b/extensions/levelcode-ai/test/creditFormat.test.js @@ -34,9 +34,15 @@ const rateMatch = /const MICROS_PER_CREDIT = (\d+);/.exec(html); assert.ok(rateMatch, 'chat.html no longer defines MICROS_PER_CREDIT'); assert.strictEqual(rateMatch[1], '10000', '1 credit must stay $0.01 — the website converts identically'); +// Digit grouping must not depend on the machine's locale — otherwise the same balance renders "1,279" +// for one user and "1.279" or "1 279" for another, and these very assertions become dependent on the +// CI runner's locale. Pull the constant from source so the check below exercises the shipped value. +const localeMatch = /const CREDIT_LOCALE = '([\w-]+)';/.exec(html); +assert.ok(localeMatch, 'chat.html must pin an explicit CREDIT_LOCALE — a bare toLocaleString() varies by environment'); + const sandbox = /** @type {any} */ ({}); new Function( - 'const MICROS_PER_CREDIT = ' + rateMatch[1] + ';\n' + 'const MICROS_PER_CREDIT = ' + rateMatch[1] + ";\nconst CREDIT_LOCALE = '" + localeMatch[1] + "';\n" + extract('toCredits') + '\n' + extract('creditBalance') + '\n' + extract('creditCost') + '\n' + 'this.toCredits = toCredits; this.creditBalance = creditBalance; this.creditCost = creditCost;' ).call(sandbox); @@ -95,6 +101,15 @@ test('ROBUST: nullish and non-numeric input never render NaN', () => { assert.strictEqual(toCredits(Infinity), 0, 'a non-finite figure must collapse to 0, not Infinity'); }); +test('LOCALE: grouping is pinned, so the same balance renders identically everywhere', () => { + // Every grouped call must pass the explicit locale. A bare toLocaleString() would render "1.279" + // on a de-DE machine and "1 279" on fr-FR — the editor and the dashboard would then disagree about + // the same number, and this file's assertions would pass or fail depending on the CI runner. + const bare = /\.toLocaleString\(\s*\)/.exec(html); + assert.strictEqual(bare, null, 'found a bare toLocaleString() in chat.html — pass CREDIT_LOCALE'); + assert.strictEqual(localeMatch[1], 'en-US', 'the dashboard groups with en-US; the bar must match'); +}); + test('CONVERSION: $1 is 100 credits, in both directions of the bar', () => { assert.strictEqual(toCredits(usd(1)), 100); assert.strictEqual(creditBalance(usd(1)), '100');