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): - **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 the `memory` tool and the memory in the prompt. Notes, knowledge bases and skills stay on this
machine. 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 - **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. 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 - **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 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() } 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 // 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 // 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 // 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 // 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" let contextFrom = "the instance"
// library: lembas — the account's library through the instance's /mcp, in place of this // 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 const lembasLibrary = memoryOnServer ? lembasLibraryServer(loaded.warnings) : undefined
if (lembasLibrary) loaded.mcp[LIBRARY_SERVER] = lembasLibrary if (lembasLibrary) loaded.mcp[LIBRARY_SERVER] = lembasLibrary
// Without a way to the server's memory, this machine's is used for now. // 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 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 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, … 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, service, no sessions or deletes sent to it, no server library,
search, fetch or embeddings, no account personalization, and no search, fetch or embeddings, no account personalization, and no
self-update. The instance still records the usage of each request, 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 } 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 // or shown there — no search or fetch through it, no embeddings, no library, no personalization
// (the local keys apply) — and the whole hardline stays. // (the local keys apply) — and the whole hardline stays.
if (isLocal()) config = localOnly(config, connections, warnings) if (isLocal()) config = localOnly(config, connections, warnings)
+17 -19
View File
@@ -170,7 +170,7 @@ export interface DeviceStart {
interval: number 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( const res = await call(
d.login.device.code, 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 /** GET /v1/usage, as the server's 2.0 answers it: counting and money, no credits. `plan`,
* number is null on an instance that keeps no credits or plans. */ * `balance`, `balance_money`, the credit `cost` fields and `device.cost_money` are null and
* `windows` empty there; they are not read. */
export interface Usage { export interface Usage {
plan: { name: string; credits_per_month: number } | null month: {
balance: number | null from: string
/** `cost` null where the instance keeps no credits. */ tokens_in: number
month: { from: string; tokens_in: number; tokens_out: number; cost: number | null; cost_money?: number | null } tokens_out: number
device: { tokens_in: number; tokens_out: number; cost: number | null; cost_money?: number | null } /** What the month cost in `currency` — null when it is in more than one currency. */
unit: string cost_money?: number | null
// Every one optional: an instance may have none of them. /** The month's cost per currency, `{ USD: 1.25, EUR: 0.4 }`: never added together. */
/** The money credits are counted in: "EUR", "USD" or "GBP". */ cost_by_currency?: Record<string, number>
}
device: { tokens_in: number; tokens_out: number }
/** The currency `month.cost_money` is in. */
currency?: string currency?: string
/** What one credit is worth, in `currency`. */ /** An administrator of the instance. */
credit_value?: number | null
/** An administrator: no limit, never refused — the spend is still counted. */
admin?: boolean admin?: boolean
/** The balance in `currency`. `month.cost_money`, `device.cost_money` likewise. */ unit?: string
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 }[]
} }
/** The usage, or undefined from an instance that does not have the endpoint. */ /** 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). */ /** The instance's own name (Example). */
instance_name?: string instance_name?: string
ca?: string ca?: string
/** What the token may do, as the instance granted it: `library` in it makes the account's /** What the token may do, as the instance granted it: `memory` in it (or the older `library`)
* library the default. An older login has none recorded until it is made again. */ * makes the account's memory the default. An older login has none recorded until it is made
* again. */
scope?: string scope?: string
/** The link protocols the instance listed when last asked: at login, and again at every /** 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. */ * 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 // personality, personality_custom, instructions — from GET /v1/me/personalization when the
// instance has it `available` (its administrator allows it); /settings writes them back there // 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. // 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 // library — with no `library:` written anywhere, a login whose token has the `memory` scope
// means the account's library (`lembas`); `library: local` written keeps this machine's. // (or the older `library`) means the account's memory (`lembas`); `library: local` written
// keeps this machine's.
import { existsSync, readFileSync } from "node:fs" import { existsSync, readFileSync } from "node:fs"
import { isLocal } from "../config/paths.ts" import { isLocal } from "../config/paths.ts"
import type { Config } from "../config/schema.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") 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 /** An amount in a currency: `€1.23`, `$1.23`, `£1.23` — or "" without one. */
* 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. */
export function money(x: number | null | undefined, currency: string | undefined): string { export function money(x: number | null | undefined, currency: string | undefined): string {
if (x === null || x === undefined || !Number.isFinite(x) || !currency) return "" if (x === null || x === undefined || !Number.isFinite(x) || !currency) return ""
try { 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. */ /** The month's cost, one amount per currency (`€2.00 · $0.75`) — never two currencies added
function moneyOf(u: Usage, given: number | null | undefined, amount: number | null | undefined): string { * together — or "" when nothing was priced. */
if (given !== null && given !== undefined) return money(given, u.currency) export function monthCost(u: Usage | undefined): string {
if (amount === null || amount === undefined || typeof u.credit_value !== "number") return "" const by = u?.month?.cost_by_currency
return money(amount * u.credit_value, u.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: what the month has cost so far, per currency; "" when nothing was
* priced or the instance cannot say. */
/** The status bar's word: the balance, when the account has a plan, with its money when the export function spendLabel(u: Usage | undefined): string {
* instance says it; `unlimited` for an administrator; "" otherwise. */ const cost = monthCost(u)
export function balanceLabel(u: Usage | undefined): string { return cost ? `${cost} this month` : ""
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))
} }
/** /usage: the detail, in lines. */ /** /usage: the detail, in lines. */
export function usageLines(connection: string, u: Usage): string[] { export function usageLines(connection: string, u: Usage): string[] {
const unit = u.unit || "credits" const out = [u.admin === true ? `${connection} (administrator)` : connection]
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 since = u.month?.from ? u.month.from.slice(0, 10) : "the start of the month" 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)}`) const cost = monthCost(u)
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)}`) if (u.month) out.push(`this month (since ${since}): ${n(u.month.tokens_in)} tokens in, ${n(u.month.tokens_out)} out${cost ? ` — ${cost}` : ""}`)
// The rolling windows: an administrator has none that bind, so none are listed. if (u.device) out.push(`this device: ${n(u.device.tokens_in)} in, ${n(u.device.tokens_out)} out`)
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}`)
}
return 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 // 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 // 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 // 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 // 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. // fetch through it, no embeddings through it, no account personalization.
import { chmodSync, existsSync, mkdirSync } from "node:fs" 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, // 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. // 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 // What the account's month has cost, in the status bar: read at the start and after a reply,
// after a reply, at most once a minute; an instance that cannot say leaves nothing there. // at most once a minute; an instance that cannot say, or nothing priced, leaves nothing there.
const [credits, setCredits] = createSignal("") const [spend, setSpend] = createSignal("")
let creditsAt = 0 let spendAt = 0
const readCredits = (force = false) => { const readSpend = (force = false) => {
if (!force && Date.now() - creditsAt < 60_000) return if (!force && Date.now() - spendAt < 60_000) return
creditsAt = Date.now() spendAt = Date.now()
void import("../lembas/personal.ts").then((m) => m.accountUsage(5000).then((r) => setCredits(m.balanceLabel(r?.usage)))).catch(() => {}) void import("../lembas/personal.ts").then((m) => m.accountUsage(5000).then((r) => setSpend(m.spendLabel(r?.usage)))).catch(() => {})
} }
readCredits(true) readSpend(true)
app.bus.on((e) => e.type === "task" && e.state === "end" && readCredits()) app.bus.on((e) => e.type === "task" && e.state === "end" && readSpend())
cmd.setCredits = (text: string) => { cmd.setSpend = (text: string) => {
creditsAt = Date.now() spendAt = Date.now()
setCredits(text) setSpend(text)
} }
const share = new Share(app, { const share = new Share(app, {
@@ -641,7 +641,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
</Show> </Show>
<Queued /> <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} /> <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> </box>
<Show when={dialog()}> <Show when={dialog()}>
<Dialog spec={dialog()!} close={() => setDialog(undefined)} /> <Dialog spec={dialog()!} close={() => setDialog(undefined)} />
+5 -5
View File
@@ -56,8 +56,8 @@ export interface CommandCtx extends Ctx {
quit(): void quit(): void
/** Start again: the binary on disk now, the config read afresh, this session. */ /** Start again: the binary on disk now, the config read afresh, this session. */
reload(): void reload(): void
/** The status bar's balance, as /usage just read it. */ /** The status bar's month's cost, as /usage just read it. */
setCredits?(text: string): void setSpend?(text: string): void
/** Open a command job's live output (components/jobview.tsx). */ /** Open a command job's live output (components/jobview.tsx). */
openJob(id: string): void openJob(id: string): void
/** Enter a subagent job: its transcript in place of the session's, the prompt box sends to it. */ /** 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", 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) { async run(c) {
// /v1/usage of the account this machine is logged in to. // /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 { try {
const r = await accountUsage() 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") 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")) c.view.info(usageLines(r.connection, r.usage).join("\n"))
} catch (e) { } catch (e) {
c.view.info(`/usage: ${(e as Error).message}`, "warn") 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 * 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 * truncated: at 80 columns a long project name cuts the left short, and a running job is the one
* thing that must stay visible. */ * 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 { theme, view, app } = useTui()
const c = () => theme().colors 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 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"> <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: 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().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: ctx().color }}>{ctx().text}</span>
<span style={{ fg: c().statusDim }}> · </span> <span style={{ fg: c().statusDim }}> · </span>
<span style={{ fg: c().info }}>{isLocal() ? "local " : ""}</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") { if (u.pathname === "/api/device/code") {
const body = (await req.json()) as Record<string, string> const body = (await req.json()) as Record<string, string>
expect(body.client_id).toStartWith("lembas-cli ") 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 }) 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") { 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 { loadConfig } from "../src/config/load.ts"
import { paths } from "../src/config/paths.ts" import { paths } from "../src/config/paths.ts"
import { personalization, savePersonalization, usage, type Personalization, type Usage } from "../src/lembas/client.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 { fetchWebui } from "../src/lembas/webui.ts"
import { fakeProvider, type Fake } from "./fake-provider.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 { function instance(o: Partial<Pick<Instance, "usage" | "personal" | "forbid">> = {}): Instance {
const state: Instance = { const state: Instance = {
url: "", 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, personal: o.personal === undefined ? { enabled: true, available: true, personality: "funny", personality_custom: "", instructions: "Answer in Slovak." } : o.personal,
puts: [], puts: [],
forbid: o.forbid ?? false, forbid: o.forbid ?? false,
@@ -82,27 +83,26 @@ afterEach(() => {
rmSync(join(paths.state, "webui"), { recursive: true, force: true }) 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() inst = instance()
const u = await usage(inst.url, "lmb_key", undefined) const u = await usage(inst.url, "lmb_key", undefined)
expect(u?.plan?.name).toBe("Hobbit") expect(spendLabel(u)).toBe("$3.77 this month")
expect(balanceLabel(u)).toBe("1,234 credits")
expect(usageLines("example", u!)).toEqual([ expect(usageLines("example", u!)).toEqual([
"example: plan Hobbit, 5,000 credits a month · balance 1,234 credits", "example",
"this month (since 2026-10-01): 120,000 tokens in, 30,000 out — 3,766 credits", "this month (since 2026-10-01): 120,000 tokens in, 30,000 out — $3.77",
"this device: 1,000 in, 500 out — 20 credits", "this device: 1,000 in, 500 out",
]) ])
// Costs null (an instance without credits): the tokens alone. // Nothing priced: the tokens alone, and nothing in the status bar.
expect(usageLines("example", { ...u!, plan: null, balance: null, month: { ...u!.month, cost: null }, device: { ...u!.device, cost: null } })).toEqual([ const free: Usage = { ...u!, month: { ...u!.month, cost_money: 0, cost_by_currency: {} } }
"example: no plan", expect(usageLines("example", free)).toEqual([
"example",
"this month (since 2026-10-01): 120,000 tokens in, 30,000 out", "this month (since 2026-10-01): 120,000 tokens in, 30,000 out",
"this device: 1,000 in, 500 out", "this device: 1,000 in, 500 out",
]) ])
// No plan, or an instance without credits: nothing in the status bar. expect(spendLabel(free)).toBe("")
expect(balanceLabel({ ...u!, plan: null })).toBe("") expect(spendLabel(undefined)).toBe("")
expect(balanceLabel({ ...u!, balance: null })).toBe("")
loggedIn(inst) 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 () => { 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() await app.close()
}) })
test("money beside the credits, two decimals where they matter, and an administrator unlimited", () => { test("money per currency, never added together, and no credit wording", () => {
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" } 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" }
// An older instance: credits alone, now with their decimals. // Two currencies: each its own amount, `cost_money` null.
expect(balanceLabel(base)).toBe("3.46 credits") expect(spendLabel(base)).toBe("€2.00 · $0.75 this month")
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(usageLines("example", base)).toEqual([
expect(balanceLabel(eur)).toBe("3.46 credits (€0.03)") "example",
expect(usageLines("example", eur)).toEqual([ "this month (since 2026-10-01): 1,000 tokens in, 200 out — €2.00 · $0.75",
"example: plan Hobbit, 5,000 credits a month · balance 3.46 credits (€0.03)", "this device: 10 in, 5 out",
"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",
]) ])
// 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/)
}) })