Usage is money per currency, no credits; login asks for memory
ci / check (push) Canceled after 0s

The login asks the server for the memory scope in place of library (the
server accepts both; library is no longer offered for new tokens).

/v1/usage is read in the server's 2.0 shape: no plan, balance, credit
costs or windows. The status bar shows what the month has cost so far and
/usage the month's tokens and cost and this device's tokens, each
currency's amount on its own (month.cost_by_currency, else cost_money in
currency). The credit wording is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-10-28 03:41:11 +00:00
1 parent 559b521ca9
commit 059d34cec1
14 files changed
+110 -127

No files matched your search

+6
View File
@@ -25,6 +25,12 @@ instruction file on the way down to where it was started. Harness spec 3.0.0.
- **Memory is the server's**, shared with your chats there (when the login's token may reach it):
the `memory` tool and the memory in the prompt. Notes, knowledge bases and skills stay on this
machine.
- **Logging in asks for the `memory` scope** in place of `library`; a login made before keeps
working with the one it has.
- **Usage is money, not credits.** The status bar shows what the month has cost so far, and
`/usage` the month's tokens and cost and this device's tokens — each currency's amount on its
own (`€2.00 · $0.75`), never added together. Plans, balances, rolling windows and the
`unlimited` of an administrator are gone with the server's credits.
- **Instruction files stack.** Every `AGENTS.md` (or `CLAUDE.md`) from the project root down to the
working directory goes to the model, outermost first — not only the nearest one.
- **Tool results are kept short when they come in, and never changed afterwards**, so the server's
+1 -1
View File
@@ -578,7 +578,7 @@ export class AcpAgent {
const mode = L ? clampMode(wanted ?? L.maxMode, L.maxMode) : wanted
const session: Session = { id: "", cwd, prompting: false, app: undefined as unknown as App, turns: new TurnLog() }
// No model-made title: the client names its own conversations (LLeMbas has its chat's), and a
// title request would be a second call on the account's credits for nothing.
// title request would be a second call the account pays for, for nothing.
// The chat's model and effort: `lembas_model` is the instance's own name for it, the
// one its /v1 serves; `model` a ref of this machine's, for an editor that knows them. A session
// the client opens on its model runs on that model and no other (spec 3.0.0): one this device
+2 -1
View File
@@ -294,7 +294,8 @@ export function createApp(o: AppOptions): App {
let contextFrom = "the instance"
// library: lembas — the account's library through the instance's /mcp, in place of this
// machine's. Needs the one instance logged in to, with a token that has the library scope.
// machine's. Needs the one instance logged in to, with a token that has the memory scope (or
// the older library).
const lembasLibrary = memoryOnServer ? lembasLibraryServer(loaded.warnings) : undefined
if (lembasLibrary) loaded.mcp[LIBRARY_SERVER] = lembasLibrary
// Without a way to the server's memory, this machine's is used for now.
+1 -1
View File
@@ -71,7 +71,7 @@ Local mode:
it. Your logins and connections.yaml are read, never written; your
config.yaml is not read (the local one is used; lembas -l config set
… writes it). A LLeMbas instance is used only for its models, chat,
speech in and out (/v1/audio) and the balance: no link, /remote or
speech in and out (/v1/audio) and the usage: no link, /remote or
service, no sessions or deletes sent to it, no server library,
search, fetch or embeddings, no account personalization, and no
self-update. The instance still records the usage of each request,
+1 -1
View File
@@ -403,7 +403,7 @@ export function loadConfig(opts: { projectConfigDir?: string; trusted?: boolean
config = { ...config, search: sr }
}
// Local mode: an instance serves models, chat, speech and the balance, and nothing that is kept
// Local mode: an instance serves models, chat, speech and the usage, and nothing that is kept
// or shown there — no search or fetch through it, no embeddings, no library, no personalization
// (the local keys apply) — and the whole hardline stays.
if (isLocal()) config = localOnly(config, connections, warnings)
+17 -19
View File
@@ -170,7 +170,7 @@ export interface DeviceStart {
interval: number
}
export async function startDevice(d: Discovery, tls: Tls | undefined, scopes = ["models", "library", "link"]): Promise<DeviceStart> {
export async function startDevice(d: Discovery, tls: Tls | undefined, scopes = ["models", "memory", "link"]): Promise<DeviceStart> {
const res = await call(
d.login.device.code,
{
@@ -282,27 +282,25 @@ export async function signOut(baseUrl: string, token: string, tls: Tls | undefin
}
}
/** GET /v1/usage: the account's credits this month, and this device's share. Every
* number is null on an instance that keeps no credits or plans. */
/** GET /v1/usage, as the server's 2.0 answers it: counting and money, no credits. `plan`,
* `balance`, `balance_money`, the credit `cost` fields and `device.cost_money` are null and
* `windows` empty there; they are not read. */
export interface Usage {
plan: { name: string; credits_per_month: number } | null
balance: number | null
/** `cost` null where the instance keeps no credits. */
month: { from: string; tokens_in: number; tokens_out: number; cost: number | null; cost_money?: number | null }
device: { tokens_in: number; tokens_out: number; cost: number | null; cost_money?: number | null }
unit: string
// Every one optional: an instance may have none of them.
/** The money credits are counted in: "EUR", "USD" or "GBP". */
month: {
from: string
tokens_in: number
tokens_out: number
/** What the month cost in `currency` — null when it is in more than one currency. */
cost_money?: number | null
/** The month's cost per currency, `{ USD: 1.25, EUR: 0.4 }`: never added together. */
cost_by_currency?: Record<string, number>
}
device: { tokens_in: number; tokens_out: number }
/** The currency `month.cost_money` is in. */
currency?: string
/** What one credit is worth, in `currency`. */
credit_value?: number | null
/** An administrator: no limit, never refused — the spend is still counted. */
/** An administrator of the instance. */
admin?: boolean
/** The balance in `currency`. `month.cost_money`, `device.cost_money` likewise. */
balance_money?: number | null
/** Rolling spending windows (e.g. five hours, a week): how much of each is used, and when the
* oldest of it frees again. */
windows?: { window: string | number; limit: number | null; spent: number; frees_at?: string | null }[]
unit?: string
}
/** The usage, or undefined from an instance that does not have the endpoint. */
+3 -2
View File
@@ -24,8 +24,9 @@ export interface Instance {
/** The instance's own name (Example). */
instance_name?: string
ca?: string
/** What the token may do, as the instance granted it: `library` in it makes the account's
* library the default. An older login has none recorded until it is made again. */
/** What the token may do, as the instance granted it: `memory` in it (or the older `library`)
* makes the account's memory the default. An older login has none recorded until it is made
* again. */
scope?: string
/** The link protocols the instance listed when last asked: at login, and again at every
* dial of the link, so an instance updated later is picked up. */
+27 -38
View File
@@ -7,8 +7,9 @@
// personality, personality_custom, instructions — from GET /v1/me/personalization when the
// instance has it `available` (its administrator allows it); /settings writes them back there
// with PUT. Read with the models at every start (webui.ts) and kept in the same cache.
// library — with no `library:` written anywhere, a login whose token has the `library` scope
// means the account's library (`lembas`); `library: local` written keeps this machine's.
// library — with no `library:` written anywhere, a login whose token has the `memory` scope
// (or the older `library`) means the account's memory (`lembas`); `library: local` written
// keeps this machine's.
import { existsSync, readFileSync } from "node:fs"
import { isLocal } from "../config/paths.ts"
import type { Config } from "../config/schema.ts"
@@ -88,13 +89,7 @@ export async function accountUsage(timeoutMs?: number): Promise<{ connection: st
const n = (x: number) => Math.round(x).toLocaleString("en")
/** Credits as a person reads them: two decimals where the number is small enough for them
* to matter (a balance of 3.47 is not "3"), whole above a thousand, as before. */
export function credits(x: number): string {
return x.toLocaleString("en", { maximumFractionDigits: Math.abs(x) < 1000 ? 2 : 0 })
}
/** An amount in the instance's currency: `€1.23`, `$1.23`, `£1.23` — or "" without one. */
/** An amount in a currency: `€1.23`, `$1.23`, `£1.23` — or "" without one. */
export function money(x: number | null | undefined, currency: string | undefined): string {
if (x === null || x === undefined || !Number.isFinite(x) || !currency) return ""
try {
@@ -105,41 +100,35 @@ export function money(x: number | null | undefined, currency: string | undefined
}
}
/** A credit amount's money, as the instance gave it, else from what a credit is worth. */
function moneyOf(u: Usage, given: number | null | undefined, amount: number | null | undefined): string {
if (given !== null && given !== undefined) return money(given, u.currency)
if (amount === null || amount === undefined || typeof u.credit_value !== "number") return ""
return money(amount * u.credit_value, u.currency)
/** The month's cost, one amount per currency (`€2.00 · $0.75`) — never two currencies added
* together — or "" when nothing was priced. */
export function monthCost(u: Usage | undefined): string {
const by = u?.month?.cost_by_currency
if (by && typeof by === "object" && !Array.isArray(by)) {
const parts = Object.entries(by)
.filter(([, v]) => typeof v === "number" && v > 0)
.sort(([a], [b]) => a.localeCompare(b))
.map(([code, v]) => money(v, code))
.filter(Boolean)
return parts.join(" · ")
}
const one = u?.month?.cost_money
return typeof one === "number" && one > 0 ? money(one, u?.currency) : ""
}
const withMoney = (text: string, m: string) => (m ? `${text} (${m})` : text)
/** The status bar's word: the balance, when the account has a plan, with its money when the
* instance says it; `unlimited` for an administrator; "" otherwise. */
export function balanceLabel(u: Usage | undefined): string {
if (u?.admin === true) return "unlimited"
if (!u?.plan || u.balance === null || u.balance === undefined) return ""
return withMoney(`${credits(u.balance)} ${u.unit || "credits"}`, moneyOf(u, u.balance_money, u.balance))
/** The status bar's word: what the month has cost so far, per currency; "" when nothing was
* priced or the instance cannot say. */
export function spendLabel(u: Usage | undefined): string {
const cost = monthCost(u)
return cost ? `${cost} this month` : ""
}
/** /usage: the detail, in lines. */
export function usageLines(connection: string, u: Usage): string[] {
const unit = u.unit || "credits"
const amount = (c: number | null | undefined, m: number | null | undefined) => (c !== null && c !== undefined ? ` — ${withMoney(`${credits(c)} ${unit}`, moneyOf(u, m, c))}` : m !== null && m !== undefined ? ` — ${money(m, u.currency)}` : "")
const head = u.admin === true
? `${connection}: unlimited (administrator)`
: `${connection}: ${u.plan ? `plan ${u.plan.name}, ${n(u.plan.credits_per_month)} ${unit} a month` : "no plan"}${u.balance !== null && u.balance !== undefined ? ` · balance ${withMoney(`${credits(u.balance)} ${unit}`, moneyOf(u, u.balance_money, u.balance))}` : ""}`
const out = [head]
const out = [u.admin === true ? `${connection} (administrator)` : connection]
const since = u.month?.from ? u.month.from.slice(0, 10) : "the start of the month"
if (u.month) out.push(`this month (since ${since}): ${n(u.month.tokens_in)} tokens in, ${n(u.month.tokens_out)} out${amount(u.month.cost, u.month.cost_money)}`)
if (u.device) out.push(`this device: ${n(u.device.tokens_in)} in, ${n(u.device.tokens_out)} out${amount(u.device.cost, u.device.cost_money)}`)
// The rolling windows: an administrator has none that bind, so none are listed.
if (u.admin !== true)
for (const w of Array.isArray(u.windows) ? u.windows : []) {
if (!w || typeof w.spent !== "number") continue
const frees = typeof w.frees_at === "string" && w.frees_at ? `, frees from ${w.frees_at.slice(0, 16).replace("T", " ")}` : ""
const of = typeof w.limit === "number" ? ` of ${credits(w.limit)}` : ""
out.push(`window ${w.window}: ${credits(w.spent)}${of} ${unit} spent${frees}`)
}
const cost = monthCost(u)
if (u.month) out.push(`this month (since ${since}): ${n(u.month.tokens_in)} tokens in, ${n(u.month.tokens_out)} out${cost ? ` — ${cost}` : ""}`)
if (u.device) out.push(`this device: ${n(u.device.tokens_in)} in, ${n(u.device.tokens_out)} out`)
return out
}
+1 -1
View File
@@ -6,7 +6,7 @@
// What it reads from outside: only the login — the user's connections.yaml, lembas.json and the key
// files they name — read-only, so the connections and sign-ins work without being copied. The
// user's config.yaml is not read; the local one is this mode's global config. A LLeMbas instance
// is used for models, chat, speech in and out, and the balance, and for nothing that is kept there
// is used for models, chat, speech in and out, and the usage, and for nothing that is kept there
// or shown there: no link, no shared sessions, no deletes sent, no server library, no search or
// fetch through it, no embeddings through it, no account personalization.
import { chmodSync, existsSync, mkdirSync } from "node:fs"
+14 -14
View File
@@ -323,20 +323,20 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
// The web UI as a second keyboard on this session: shared through the service by itself,
// or by name with /remote. What it sends runs here as if typed, and is shown as such.
// The account's balance in the status bar, when it has a plan: read at the start and
// after a reply, at most once a minute; an instance that cannot say leaves nothing there.
const [credits, setCredits] = createSignal("")
let creditsAt = 0
const readCredits = (force = false) => {
if (!force && Date.now() - creditsAt < 60_000) return
creditsAt = Date.now()
void import("../lembas/personal.ts").then((m) => m.accountUsage(5000).then((r) => setCredits(m.balanceLabel(r?.usage)))).catch(() => {})
// What the account's month has cost, in the status bar: read at the start and after a reply,
// at most once a minute; an instance that cannot say, or nothing priced, leaves nothing there.
const [spend, setSpend] = createSignal("")
let spendAt = 0
const readSpend = (force = false) => {
if (!force && Date.now() - spendAt < 60_000) return
spendAt = Date.now()
void import("../lembas/personal.ts").then((m) => m.accountUsage(5000).then((r) => setSpend(m.spendLabel(r?.usage)))).catch(() => {})
}
readCredits(true)
app.bus.on((e) => e.type === "task" && e.state === "end" && readCredits())
cmd.setCredits = (text: string) => {
creditsAt = Date.now()
setCredits(text)
readSpend(true)
app.bus.on((e) => e.type === "task" && e.state === "end" && readSpend())
cmd.setSpend = (text: string) => {
spendAt = Date.now()
setSpend(text)
}
const share = new Share(app, {
@@ -641,7 +641,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
</Show>
<Queued />
<Prompt focused={() => !dialog() && !jobPanel() && !view.state.pending && !view.state.question && !view.state.plan} busy={busy} commands={palette} files={files} onSubmit={submit} ref={(r) => (input = r)} queue={queueAccess} onWheel={(dir, lines) => scroll?.scrollBy(dir * lines)} onBackground={backgroundNow} />
<StatusBar mode={mode} model={model} effort={effort} branch={branch()} voice={voiceStatus} flash={flashText} remote={() => (rev(), share.label())} credits={credits} />
<StatusBar mode={mode} model={model} effort={effort} branch={branch()} voice={voiceStatus} flash={flashText} remote={() => (rev(), share.label())} spend={spend} />
</box>
<Show when={dialog()}>
<Dialog spec={dialog()!} close={() => setDialog(undefined)} />
+5 -5
View File
@@ -56,8 +56,8 @@ export interface CommandCtx extends Ctx {
quit(): void
/** Start again: the binary on disk now, the config read afresh, this session. */
reload(): void
/** The status bar's balance, as /usage just read it. */
setCredits?(text: string): void
/** The status bar's month's cost, as /usage just read it. */
setSpend?(text: string): void
/** Open a command job's live output (components/jobview.tsx). */
openJob(id: string): void
/** Enter a subagent job: its transcript in place of the session's, the prompt box sends to it. */
@@ -865,14 +865,14 @@ export const COMMANDS: Command[] = [
},
{
name: "usage",
description: "your account's credits on the LLeMbas instance: plan, balance, this month, this device",
description: "your account's usage on the LLeMbas instance: this month's tokens and cost, this device's tokens",
async run(c) {
// /v1/usage of the account this machine is logged in to.
const { accountUsage, balanceLabel, usageLines } = await import("../lembas/personal.ts")
const { accountUsage, spendLabel, usageLines } = await import("../lembas/personal.ts")
try {
const r = await accountUsage()
if (!r) return c.view.info("no usage to show — not logged in to one LLeMbas instance (/login), or it does not keep any")
c.setCredits?.(balanceLabel(r.usage))
c.setSpend?.(spendLabel(r.usage))
c.view.info(usageLines(r.connection, r.usage).join("\n"))
} catch (e) {
c.view.info(`/usage: ${(e as Error).message}`, "warn")
+2 -2
View File
@@ -22,7 +22,7 @@ export function jobsWord(jobs: { state: string; unread: number; unseen?: boolean
* for a moment, a flash (a copy) in their place. The jobs word sits on the right, which is never
* truncated: at 80 columns a long project name cuts the left short, and a running job is the one
* thing that must stay visible. */
export function StatusBar(p: { mode: () => string; model: () => string; effort: () => string; branch?: string; voice?: () => string; flash?: () => string | undefined; remote?: () => string; credits?: () => string }) {
export function StatusBar(p: { mode: () => string; model: () => string; effort: () => string; branch?: string; voice?: () => string; flash?: () => string | undefined; remote?: () => string; spend?: () => string }) {
const { theme, view, app } = useTui()
const c = () => theme().colors
const modeColor = () => ({ manual: c().statusText, edit: c().statusGood, auto: c().statusCritical, plan: c().info })[p.mode() as keyof typeof MODE_LABEL] ?? c().statusText
@@ -52,7 +52,7 @@ export function StatusBar(p: { mode: () => string; model: () => string; effort:
<text wrapMode="none">
<span style={{ fg: jobsWord(view.state.jobs).endsWith("!") ? c().statusWarn : c().statusText }}>{jobsWord(view.state.jobs) ? `${jobsWord(view.state.jobs)} · ` : ""}</span>
<span style={{ fg: c().statusDim }}>{view.state.rate ? `${Math.round(view.state.rate)} tok/s · ` : ""}</span>
<span style={{ fg: c().statusText }}>{p.credits?.() ? `${p.credits()} · ` : ""}</span>
<span style={{ fg: c().statusText }}>{p.spend?.() ? `${p.spend()} · ` : ""}</span>
<span style={{ fg: ctx().color }}>{ctx().text}</span>
<span style={{ fg: c().statusDim }}> · </span>
<span style={{ fg: c().info }}>{isLocal() ? "local " : ""}</span>
+1 -1
View File
@@ -88,7 +88,7 @@ function lembas(o: Partial<Pick<Fake, "approveAfter" | "deny" | "protocol" | "mo
if (u.pathname === "/api/device/code") {
const body = (await req.json()) as Record<string, string>
expect(body.client_id).toStartWith("lembas-cli ")
expect(body.scope).toBe("models library link")
expect(body.scope).toBe("models memory link")
return Response.json({ device_code: "dev-123", user_code: "CDFG-HJKM", verification_uri: `${base}/device`, verification_uri_complete: `${base}/device?code=CDFG-HJKM`, expires_in: 600, interval: 1 })
}
if (u.pathname === "/api/device/token") {
+29 -41
View File
@@ -9,7 +9,7 @@ import { createApp } from "../src/app.ts"
import { loadConfig } from "../src/config/load.ts"
import { paths } from "../src/config/paths.ts"
import { personalization, savePersonalization, usage, type Personalization, type Usage } from "../src/lembas/client.ts"
import { accountUsage, balanceLabel, defaultLibrary, usageLines } from "../src/lembas/personal.ts"
import { accountUsage, defaultLibrary, spendLabel, usageLines } from "../src/lembas/personal.ts"
import { fetchWebui } from "../src/lembas/webui.ts"
import { fakeProvider, type Fake } from "./fake-provider.ts"
@@ -28,7 +28,8 @@ let model: Fake | undefined
function instance(o: Partial<Pick<Instance, "usage" | "personal" | "forbid">> = {}): Instance {
const state: Instance = {
url: "",
usage: o.usage === undefined ? { plan: { name: "Hobbit", credits_per_month: 5000 }, balance: 1234.4, month: { from: "2026-10-01T00:00:00Z", tokens_in: 120000, tokens_out: 30000, cost: 3765.6 }, device: { tokens_in: 1000, tokens_out: 500, cost: 20 }, unit: "credits" } : o.usage,
// The server's 2.0 answer: the credit fields null, `windows` empty, money per currency.
usage: o.usage === undefined ? ({ plan: null, balance: null, balance_money: null, month: { from: "2026-10-01T00:00:00Z", tokens_in: 120000, tokens_out: 30000, cost: null, cost_money: 3.7656, cost_by_currency: { USD: 3.7656 } }, device: { tokens_in: 1000, tokens_out: 500, cost: null, cost_money: null }, windows: [], currency: "USD", admin: false, unit: "money" } as Usage) : o.usage,
personal: o.personal === undefined ? { enabled: true, available: true, personality: "funny", personality_custom: "", instructions: "Answer in Slovak." } : o.personal,
puts: [],
forbid: o.forbid ?? false,
@@ -82,27 +83,26 @@ afterEach(() => {
rmSync(join(paths.state, "webui"), { recursive: true, force: true })
})
test("/v1/usage: read, as a balance for the status bar and lines for /usage", async () => {
test("/v1/usage: read, as the month's cost for the status bar and lines for /usage", async () => {
inst = instance()
const u = await usage(inst.url, "lmb_key", undefined)
expect(u?.plan?.name).toBe("Hobbit")
expect(balanceLabel(u)).toBe("1,234 credits")
expect(spendLabel(u)).toBe("$3.77 this month")
expect(usageLines("example", u!)).toEqual([
"example: plan Hobbit, 5,000 credits a month · balance 1,234 credits",
"this month (since 2026-10-01): 120,000 tokens in, 30,000 out — 3,766 credits",
"this device: 1,000 in, 500 out — 20 credits",
"example",
"this month (since 2026-10-01): 120,000 tokens in, 30,000 out — $3.77",
"this device: 1,000 in, 500 out",
])
// Costs null (an instance without credits): the tokens alone.
expect(usageLines("example", { ...u!, plan: null, balance: null, month: { ...u!.month, cost: null }, device: { ...u!.device, cost: null } })).toEqual([
"example: no plan",
// Nothing priced: the tokens alone, and nothing in the status bar.
const free: Usage = { ...u!, month: { ...u!.month, cost_money: 0, cost_by_currency: {} } }
expect(usageLines("example", free)).toEqual([
"example",
"this month (since 2026-10-01): 120,000 tokens in, 30,000 out",
"this device: 1,000 in, 500 out",
])
// No plan, or an instance without credits: nothing in the status bar.
expect(balanceLabel({ ...u!, plan: null })).toBe("")
expect(balanceLabel({ ...u!, balance: null })).toBe("")
expect(spendLabel(free)).toBe("")
expect(spendLabel(undefined)).toBe("")
loggedIn(inst)
expect((await accountUsage())?.usage.balance).toBe(1234.4)
expect((await accountUsage())?.usage.month.tokens_in).toBe(120000)
})
test("an instance without the endpoints: no usage, no personalization, no error", async () => {
@@ -206,31 +206,19 @@ test("opted out on the web, or a preset the CLI does not know: setting one key h
await app.close()
})
test("money beside the credits, two decimals where they matter, and an administrator unlimited", () => {
const base: Usage = { plan: { name: "Hobbit", credits_per_month: 5000 }, balance: 3.456, month: { from: "2026-10-01T00:00:00Z", tokens_in: 1000, tokens_out: 200, cost: 12.5 }, device: { tokens_in: 10, tokens_out: 5, cost: 0.25 }, unit: "credits" }
// An older instance: credits alone, now with their decimals.
expect(balanceLabel(base)).toBe("3.46 credits")
const eur: Usage = { ...base, currency: "EUR", credit_value: 0.01, balance_money: 0.03, month: { ...base.month, cost_money: 0.13 }, device: { ...base.device, cost_money: null } }
expect(balanceLabel(eur)).toBe("3.46 credits (€0.03)")
expect(usageLines("example", eur)).toEqual([
"example: plan Hobbit, 5,000 credits a month · balance 3.46 credits (€0.03)",
"this month (since 2026-10-01): 1,000 tokens in, 200 out — 12.5 credits (€0.13)",
// No money sent for it: worked out from what a credit is worth.
"this device: 10 in, 5 out — 0.25 credits (€0.00)",
])
expect(balanceLabel({ ...base, currency: "USD", balance_money: 1.5 })).toBe("3.46 credits ($1.50)")
expect(balanceLabel({ ...base, currency: "GBP", balance_money: 1234.5, balance: 123456 })).toBe("123,456 credits (£1,234.50)")
// An administrator: unlimited, plan or not, and the month's spend still listed.
const admin: Usage = { ...eur, plan: null, balance: null, admin: true, windows: [{ window: "5h", limit: 100, spent: 50, frees_at: null }] }
expect(balanceLabel(admin)).toBe("unlimited")
expect(usageLines("example", admin)).toEqual([
"example: unlimited (administrator)",
"this month (since 2026-10-01): 1,000 tokens in, 200 out — 12.5 credits (€0.13)",
"this device: 10 in, 5 out — 0.25 credits (€0.00)",
])
// Windows: spent, of what, and when it frees.
expect(usageLines("example", { ...base, windows: [{ window: "5h", limit: 200, spent: 150.25, frees_at: "2026-10-09T14:05:00Z" }, { window: "week", limit: null, spent: 900 }] }).slice(3)).toEqual([
"window 5h: 150.25 of 200 credits spent, frees from 2026-10-09 14:05",
"window week: 900 credits spent",
test("money per currency, never added together, and no credit wording", () => {
const base: Usage = { month: { from: "2026-10-01T00:00:00Z", tokens_in: 1000, tokens_out: 200, cost_money: null, cost_by_currency: { USD: 0.75, EUR: 2 } }, device: { tokens_in: 10, tokens_out: 5 }, currency: "USD", unit: "money" }
// Two currencies: each its own amount, `cost_money` null.
expect(spendLabel(base)).toBe("€2.00 · $0.75 this month")
expect(usageLines("example", base)).toEqual([
"example",
"this month (since 2026-10-01): 1,000 tokens in, 200 out — €2.00 · $0.75",
"this device: 10 in, 5 out",
])
// One currency, as `cost_money` alone (no breakdown sent): in `currency`.
expect(spendLabel({ ...base, currency: "GBP", month: { ...base.month, cost_money: 1234.5, cost_by_currency: undefined } })).toBe("£1,234.50 this month")
// An administrator is said so; the spend is the same.
const admin: Usage = { ...base, admin: true }
expect(usageLines("example", admin)[0]).toBe("example (administrator)")
for (const line of [...usageLines("example", admin), spendLabel(admin)]) expect(line).not.toMatch(/credit|balance|plan|unlimited|window/)
})