diff --git a/extensions/levelcode-ai/media/chat.html b/extensions/levelcode-ai/media/chat.html index f5154bc..ed6a948 100644 --- a/extensions/levelcode-ai/media/chat.html +++ b/extensions/levelcode-ai/media/chat.html @@ -2419,15 +2419,39 @@ 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. - 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); + // [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; + // 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; + } + // 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(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 + // 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(CREDIT_LOCALE); } + 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'; @@ -2447,8 +2471,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(); diff --git a/extensions/levelcode-ai/test/creditFormat.test.js b/extensions/levelcode-ai/test/creditFormat.test.js new file mode 100644 index 0000000..1db5668 --- /dev/null +++ b/extensions/levelcode-ai/test/creditFormat.test.js @@ -0,0 +1,119 @@ +/*--------------------------------------------------------------------------------------------- + * 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'); + +// 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] + ";\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); + +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('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'); + assert.strictEqual(creditCost(usd(1)), '100'); +}); + +console.log('\ncreditFormat (chat.html): ' + n + ' tests passed.');