From 059d34cec14500e9bf44df88ff7b15a1f539c2de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Wed, 28 Oct 2026 03:41:11 +0000 Subject: [PATCH] Usage is money per currency, no credits; login asks for memory 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 --- CHANGELOG.md | 6 +++ src/acp/agent.ts | 2 +- src/app.ts | 3 +- src/cli.ts | 2 +- src/config/load.ts | 2 +- src/lembas/client.ts | 36 +++++++++--------- src/lembas/login.ts | 5 ++- src/lembas/personal.ts | 65 ++++++++++++++------------------ src/project/local.ts | 2 +- src/tui/app.tsx | 28 +++++++------- src/tui/commands.ts | 10 ++--- src/tui/components/status.tsx | 4 +- tests/lembas-login.test.ts | 2 +- tests/personal.test.ts | 70 +++++++++++++++-------------------- 14 files changed, 110 insertions(+), 127 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07dba51..f34cf7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/acp/agent.ts b/src/acp/agent.ts index b03553e..219c3bf 100644 --- a/src/acp/agent.ts +++ b/src/acp/agent.ts @@ -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 diff --git a/src/app.ts b/src/app.ts index ef08f89..449d3c1 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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. diff --git a/src/cli.ts b/src/cli.ts index f0e9cb3..9834566 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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, diff --git a/src/config/load.ts b/src/config/load.ts index b53b13c..54ece62 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -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) diff --git a/src/lembas/client.ts b/src/lembas/client.ts index 8518508..1a55318 100644 --- a/src/lembas/client.ts +++ b/src/lembas/client.ts @@ -170,7 +170,7 @@ export interface DeviceStart { interval: number } -export async function startDevice(d: Discovery, tls: Tls | undefined, scopes = ["models", "library", "link"]): Promise { +export async function startDevice(d: Discovery, tls: Tls | undefined, scopes = ["models", "memory", "link"]): Promise { 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 + } + 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. */ diff --git a/src/lembas/login.ts b/src/lembas/login.ts index b918328..7c3fd43 100644 --- a/src/lembas/login.ts +++ b/src/lembas/login.ts @@ -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. */ diff --git a/src/lembas/personal.ts b/src/lembas/personal.ts index b616ab9..e92eb42 100644 --- a/src/lembas/personal.ts +++ b/src/lembas/personal.ts @@ -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 } diff --git a/src/project/local.ts b/src/project/local.ts index 16b24a7..f401dce 100644 --- a/src/project/local.ts +++ b/src/project/local.ts @@ -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" diff --git a/src/tui/app.tsx b/src/tui/app.tsx index cf59553..45c17cf 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -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: () => !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} /> - (rev(), share.label())} credits={credits} /> + (rev(), share.label())} spend={spend} /> setDialog(undefined)} /> diff --git a/src/tui/commands.ts b/src/tui/commands.ts index 791ed09..0d6aa19 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -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") diff --git a/src/tui/components/status.tsx b/src/tui/components/status.tsx index 8fd3b7b..a84d358 100644 --- a/src/tui/components/status.tsx +++ b/src/tui/components/status.tsx @@ -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: {jobsWord(view.state.jobs) ? `${jobsWord(view.state.jobs)} · ` : ""} {view.state.rate ? `${Math.round(view.state.rate)} tok/s · ` : ""} - {p.credits?.() ? `${p.credits()} · ` : ""} + {p.spend?.() ? `${p.spend()} · ` : ""} {ctx().text} · {isLocal() ? "local " : ""} diff --git a/tests/lembas-login.test.ts b/tests/lembas-login.test.ts index 834e6ad..01894cf 100644 --- a/tests/lembas-login.test.ts +++ b/tests/lembas-login.test.ts @@ -88,7 +88,7 @@ function lembas(o: Partial 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") { diff --git a/tests/personal.test.ts b/tests/personal.test.ts index 1c6a4e6..643a9d4 100644 --- a/tests/personal.test.ts +++ b/tests/personal.test.ts @@ -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> = {}): 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/) })