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:
14 files changed
+110
-127
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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/)
|
||||||
})
|
})
|
||||||
Reference in new issue
Block a user