Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 33 additions & 9 deletions extensions/levelcode-ai/media/chat.html
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -2447,8 +2471,8 @@
+ '<span class="abmodel" title="Click to change model">' + esc(modelLabel()) + '</span>'
// 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 ? '<span class="abcredits" title="What this run cost">· ' + esc(money(costMicros)) + '</span>' : '')
+ (credits != null ? '<span class="abcredits" title="Credits remaining on your plan">· ' + esc(money(credits)) + ' left</span>' : '');
+ (costMicros != null ? '<span class="abcredits" title="What this run cost">· ' + esc(creditCost(costMicros)) + ' credits</span>' : '')
+ (credits != null ? '<span class="abcredits" title="Credits remaining on your plan">· ' + esc(creditBalance(credits)) + ' left</span>' : '');
bar.append(actions, meta);
log.appendChild(bar); scrollIfStuck();

Expand Down
119 changes: 119 additions & 0 deletions extensions/levelcode-ai/test/creditFormat.test.js
Original file line number Diff line number Diff line change
@@ -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.');