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
+1
-1
@@ -578,7 +578,7 @@ export class AcpAgent {
|
||||
const mode = L ? clampMode(wanted ?? L.maxMode, L.maxMode) : wanted
|
||||
const session: Session = { id: "", cwd, prompting: false, app: undefined as unknown as App, turns: new TurnLog() }
|
||||
// No model-made title: the client names its own conversations (LLeMbas has its chat's), and a
|
||||
// title request would be a second call on the account's credits for nothing.
|
||||
// title request would be a second call the account pays for, for nothing.
|
||||
// The chat's model and effort: `lembas_model` is the instance's own name for it, the
|
||||
// one its /v1 serves; `model` a ref of this machine's, for an editor that knows them. A session
|
||||
// the client opens on its model runs on that model and no other (spec 3.0.0): one this device
|
||||
|
||||
+2
-1
@@ -294,7 +294,8 @@ export function createApp(o: AppOptions): App {
|
||||
let contextFrom = "the instance"
|
||||
|
||||
// library: lembas — the account's library through the instance's /mcp, in place of this
|
||||
// machine's. Needs the one instance logged in to, with a token that has the library scope.
|
||||
// machine's. Needs the one instance logged in to, with a token that has the memory scope (or
|
||||
// the older library).
|
||||
const lembasLibrary = memoryOnServer ? lembasLibraryServer(loaded.warnings) : undefined
|
||||
if (lembasLibrary) loaded.mcp[LIBRARY_SERVER] = lembasLibrary
|
||||
// Without a way to the server's memory, this machine's is used for now.
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ Local mode:
|
||||
it. Your logins and connections.yaml are read, never written; your
|
||||
config.yaml is not read (the local one is used; lembas -l config set
|
||||
… writes it). A LLeMbas instance is used only for its models, chat,
|
||||
speech in and out (/v1/audio) and the balance: no link, /remote or
|
||||
speech in and out (/v1/audio) and the usage: no link, /remote or
|
||||
service, no sessions or deletes sent to it, no server library,
|
||||
search, fetch or embeddings, no account personalization, and no
|
||||
self-update. The instance still records the usage of each request,
|
||||
|
||||
+1
-1
@@ -403,7 +403,7 @@ export function loadConfig(opts: { projectConfigDir?: string; trusted?: boolean
|
||||
config = { ...config, search: sr }
|
||||
}
|
||||
|
||||
// Local mode: an instance serves models, chat, speech and the balance, and nothing that is kept
|
||||
// Local mode: an instance serves models, chat, speech and the usage, and nothing that is kept
|
||||
// or shown there — no search or fetch through it, no embeddings, no library, no personalization
|
||||
// (the local keys apply) — and the whole hardline stays.
|
||||
if (isLocal()) config = localOnly(config, connections, warnings)
|
||||
|
||||
+17
-19
@@ -170,7 +170,7 @@ export interface DeviceStart {
|
||||
interval: number
|
||||
}
|
||||
|
||||
export async function startDevice(d: Discovery, tls: Tls | undefined, scopes = ["models", "library", "link"]): Promise<DeviceStart> {
|
||||
export async function startDevice(d: Discovery, tls: Tls | undefined, scopes = ["models", "memory", "link"]): Promise<DeviceStart> {
|
||||
const res = await call(
|
||||
d.login.device.code,
|
||||
{
|
||||
@@ -282,27 +282,25 @@ export async function signOut(baseUrl: string, token: string, tls: Tls | undefin
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /v1/usage: the account's credits this month, and this device's share. Every
|
||||
* number is null on an instance that keeps no credits or plans. */
|
||||
/** GET /v1/usage, as the server's 2.0 answers it: counting and money, no credits. `plan`,
|
||||
* `balance`, `balance_money`, the credit `cost` fields and `device.cost_money` are null and
|
||||
* `windows` empty there; they are not read. */
|
||||
export interface Usage {
|
||||
plan: { name: string; credits_per_month: number } | null
|
||||
balance: number | null
|
||||
/** `cost` null where the instance keeps no credits. */
|
||||
month: { from: string; tokens_in: number; tokens_out: number; cost: number | null; cost_money?: number | null }
|
||||
device: { tokens_in: number; tokens_out: number; cost: number | null; cost_money?: number | null }
|
||||
unit: string
|
||||
// Every one optional: an instance may have none of them.
|
||||
/** The money credits are counted in: "EUR", "USD" or "GBP". */
|
||||
month: {
|
||||
from: string
|
||||
tokens_in: number
|
||||
tokens_out: number
|
||||
/** What the month cost in `currency` — null when it is in more than one currency. */
|
||||
cost_money?: number | null
|
||||
/** The month's cost per currency, `{ USD: 1.25, EUR: 0.4 }`: never added together. */
|
||||
cost_by_currency?: Record<string, number>
|
||||
}
|
||||
device: { tokens_in: number; tokens_out: number }
|
||||
/** The currency `month.cost_money` is in. */
|
||||
currency?: string
|
||||
/** What one credit is worth, in `currency`. */
|
||||
credit_value?: number | null
|
||||
/** An administrator: no limit, never refused — the spend is still counted. */
|
||||
/** An administrator of the instance. */
|
||||
admin?: boolean
|
||||
/** The balance in `currency`. `month.cost_money`, `device.cost_money` likewise. */
|
||||
balance_money?: number | null
|
||||
/** Rolling spending windows (e.g. five hours, a week): how much of each is used, and when the
|
||||
* oldest of it frees again. */
|
||||
windows?: { window: string | number; limit: number | null; spent: number; frees_at?: string | null }[]
|
||||
unit?: string
|
||||
}
|
||||
|
||||
/** The usage, or undefined from an instance that does not have the endpoint. */
|
||||
|
||||
+3
-2
@@ -24,8 +24,9 @@ export interface Instance {
|
||||
/** The instance's own name (Example). */
|
||||
instance_name?: string
|
||||
ca?: string
|
||||
/** What the token may do, as the instance granted it: `library` in it makes the account's
|
||||
* library the default. An older login has none recorded until it is made again. */
|
||||
/** What the token may do, as the instance granted it: `memory` in it (or the older `library`)
|
||||
* makes the account's memory the default. An older login has none recorded until it is made
|
||||
* again. */
|
||||
scope?: string
|
||||
/** The link protocols the instance listed when last asked: at login, and again at every
|
||||
* dial of the link, so an instance updated later is picked up. */
|
||||
|
||||
+27
-38
@@ -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
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
// What it reads from outside: only the login — the user's connections.yaml, lembas.json and the key
|
||||
// files they name — read-only, so the connections and sign-ins work without being copied. The
|
||||
// user's config.yaml is not read; the local one is this mode's global config. A LLeMbas instance
|
||||
// is used for models, chat, speech in and out, and the balance, and for nothing that is kept there
|
||||
// is used for models, chat, speech in and out, and the usage, and for nothing that is kept there
|
||||
// or shown there: no link, no shared sessions, no deletes sent, no server library, no search or
|
||||
// fetch through it, no embeddings through it, no account personalization.
|
||||
import { chmodSync, existsSync, mkdirSync } from "node:fs"
|
||||
|
||||
+14
-14
@@ -323,20 +323,20 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
||||
|
||||
// The web UI as a second keyboard on this session: shared through the service by itself,
|
||||
// or by name with /remote. What it sends runs here as if typed, and is shown as such.
|
||||
// The account's balance in the status bar, when it has a plan: read at the start and
|
||||
// after a reply, at most once a minute; an instance that cannot say leaves nothing there.
|
||||
const [credits, setCredits] = createSignal("")
|
||||
let creditsAt = 0
|
||||
const readCredits = (force = false) => {
|
||||
if (!force && Date.now() - creditsAt < 60_000) return
|
||||
creditsAt = Date.now()
|
||||
void import("../lembas/personal.ts").then((m) => m.accountUsage(5000).then((r) => setCredits(m.balanceLabel(r?.usage)))).catch(() => {})
|
||||
// What the account's month has cost, in the status bar: read at the start and after a reply,
|
||||
// at most once a minute; an instance that cannot say, or nothing priced, leaves nothing there.
|
||||
const [spend, setSpend] = createSignal("")
|
||||
let spendAt = 0
|
||||
const readSpend = (force = false) => {
|
||||
if (!force && Date.now() - spendAt < 60_000) return
|
||||
spendAt = Date.now()
|
||||
void import("../lembas/personal.ts").then((m) => m.accountUsage(5000).then((r) => setSpend(m.spendLabel(r?.usage)))).catch(() => {})
|
||||
}
|
||||
readCredits(true)
|
||||
app.bus.on((e) => e.type === "task" && e.state === "end" && readCredits())
|
||||
cmd.setCredits = (text: string) => {
|
||||
creditsAt = Date.now()
|
||||
setCredits(text)
|
||||
readSpend(true)
|
||||
app.bus.on((e) => e.type === "task" && e.state === "end" && readSpend())
|
||||
cmd.setSpend = (text: string) => {
|
||||
spendAt = Date.now()
|
||||
setSpend(text)
|
||||
}
|
||||
|
||||
const share = new Share(app, {
|
||||
@@ -641,7 +641,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
||||
</Show>
|
||||
<Queued />
|
||||
<Prompt focused={() => !dialog() && !jobPanel() && !view.state.pending && !view.state.question && !view.state.plan} busy={busy} commands={palette} files={files} onSubmit={submit} ref={(r) => (input = r)} queue={queueAccess} onWheel={(dir, lines) => scroll?.scrollBy(dir * lines)} onBackground={backgroundNow} />
|
||||
<StatusBar mode={mode} model={model} effort={effort} branch={branch()} voice={voiceStatus} flash={flashText} remote={() => (rev(), share.label())} credits={credits} />
|
||||
<StatusBar mode={mode} model={model} effort={effort} branch={branch()} voice={voiceStatus} flash={flashText} remote={() => (rev(), share.label())} spend={spend} />
|
||||
</box>
|
||||
<Show when={dialog()}>
|
||||
<Dialog spec={dialog()!} close={() => setDialog(undefined)} />
|
||||
|
||||
+5
-5
@@ -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")
|
||||
|
||||
@@ -22,7 +22,7 @@ export function jobsWord(jobs: { state: string; unread: number; unseen?: boolean
|
||||
* for a moment, a flash (a copy) in their place. The jobs word sits on the right, which is never
|
||||
* truncated: at 80 columns a long project name cuts the left short, and a running job is the one
|
||||
* thing that must stay visible. */
|
||||
export function StatusBar(p: { mode: () => string; model: () => string; effort: () => string; branch?: string; voice?: () => string; flash?: () => string | undefined; remote?: () => string; credits?: () => string }) {
|
||||
export function StatusBar(p: { mode: () => string; model: () => string; effort: () => string; branch?: string; voice?: () => string; flash?: () => string | undefined; remote?: () => string; spend?: () => string }) {
|
||||
const { theme, view, app } = useTui()
|
||||
const c = () => theme().colors
|
||||
const modeColor = () => ({ manual: c().statusText, edit: c().statusGood, auto: c().statusCritical, plan: c().info })[p.mode() as keyof typeof MODE_LABEL] ?? c().statusText
|
||||
@@ -52,7 +52,7 @@ export function StatusBar(p: { mode: () => string; model: () => string; effort:
|
||||
<text wrapMode="none">
|
||||
<span style={{ fg: jobsWord(view.state.jobs).endsWith("!") ? c().statusWarn : c().statusText }}>{jobsWord(view.state.jobs) ? `${jobsWord(view.state.jobs)} · ` : ""}</span>
|
||||
<span style={{ fg: c().statusDim }}>{view.state.rate ? `${Math.round(view.state.rate)} tok/s · ` : ""}</span>
|
||||
<span style={{ fg: c().statusText }}>{p.credits?.() ? `${p.credits()} · ` : ""}</span>
|
||||
<span style={{ fg: c().statusText }}>{p.spend?.() ? `${p.spend()} · ` : ""}</span>
|
||||
<span style={{ fg: ctx().color }}>{ctx().text}</span>
|
||||
<span style={{ fg: c().statusDim }}> · </span>
|
||||
<span style={{ fg: c().info }}>{isLocal() ? "local " : ""}</span>
|
||||
|
||||
Reference in new issue
Block a user