Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,9 @@
# admin surface is registered at all. Not mutable at runtime — restart to change.
# MUXLL_ADMIN_PUBKEYS=

# --- Billing (Phase 1: prepaid USD balance, admin-funded) --------------------
# Set MUXLL_BILLING=1 to enable per-caller billing: chat.complete caps output
# tokens to what the caller's balance can afford and debits the real cost after
# the turn. Disabled by default (chat is free, as before). Lightning top-up and
# the CEP-8 payment wrap arrive in a later phase; for now balances are funded
# via admin.balance.credit.
# --- Billing (prepaid USD balance + CEP-8 Lightning top-up) -------------------
# Set MUXLL_BILLING=1 to enable per-caller billing: chat.complete is authorized
# by CEP-8 against the prepaid balance, then caps output and debits actual usage.
# MUXLL_BILLING=1
#
# SQLite file backing the ledger (default ./muxll-ledger.sqlite).
Expand All @@ -51,6 +48,12 @@
#
# Floor charge in USD applied to every chargeable request (anti-spam).
# MUXLL_MIN_CHARGE_USD=0.0001
# NIP-47 server wallet connection used to issue and verify top-up invoices.
# NWC_SERVER_CONNECTION=nostr+walletconnect://...
# USD represented by one satoshi for top-up quotes (refresh this operator-side).
# MUXLL_USD_PER_SAT=0.0005
# Optional exchange-rate safety buffer for top-up quotes.
# MUXLL_TOPUP_BUFFER_PCT=5

# --- Upstream LLM provider API keys ------------------------------------------
# Consumed by pi-ai's built-in providers (see @earendil-works/pi-ai). Only the
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,4 +277,4 @@ Configuration is entirely via environment variables (see `.env.example`).
becomes `{type:"tool",name}`; google can't pin a specific function).
Still deferred via the `onPayload` seam: `response_format` and sampling
params (`top_p`/`stop`/`seed`/penalties).
- Billing (Phase 1) is opt-in via `MUXLL_BILLING=1`: `Ledger` (`bun:sqlite`, USD stored as integer nano-dollars) keys balances on the authenticated caller pubkey; `chat.complete` caps output tokens to what the balance affords (`computeOutputCap`, in `pricing.ts`) and debits the real cost (recomputed via pi-ai's `calculateCost`, since the faux provider reports `cost.total = 0`) × `(1+MUXLL_MARKUP_PCT/100)`. `balance.*` / `admin.balance.*` tools fund/query balances. Native-Lightning top-up and the CEP-8 `withServerPayments` wrap are the next phases; see `docs/billing.md`. Billing is disabled entirely when no `ledger` is passed to `startServer`, so existing callers are unaffected.
- Billing is opt-in via `MUXLL_BILLING=1`: `Ledger` (`bun:sqlite`, USD stored as integer nano-dollars) keys balances on the authenticated caller pubkey; CEP-8 `resolvePrice` waives funded `chat.complete` calls or rejects unfunded callers, while the handler caps output and debits actual usage. `balance.topup` is a priced CEP-8 capability backed by the configured NWC processor; `balance.*` / `admin.balance.*` tools fund/query balances. Billing is disabled entirely when no `ledger` is passed to `startServer`, so existing callers are unaffected.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ Proof of concept. Deliberately deferred until the core is validated:
- **Stream cancellation** — a client CEP-41 `abort` does not yet cancel the upstream provider stream.
- **Structured stream chunks** — `text_delta`, `thinking_delta`, and tool-call deltas are all streamed as `chat.completion.chunk` deltas (`delta.content`, `delta.reasoning_content`, `delta.tool_calls`).
- **OpenAI feature gaps** — structured outputs (`response_format`) and sampling params (`top_p`, `stop`, `seed`, penalties) are supported per-API by pi-ai but not yet surfaced (need the `onPayload` seam). Function/tool calling is fully wired (`tools`, `tool_choice` normalized per target API, `tool` role); multimodal image input is wired (`user` content accepts `text` + `image_url` parts, data-URL or http(s)).
- **Pricing & quotas** — Phase 1 landed: a prepaid USD balance ledger (`bun:sqlite`), a `maxTokens` cap that bounds each request to what the caller's balance affords, markup-over-upstream pricing, and `balance.*` / `admin.balance.*` tools (balances funded via `admin.balance.credit`). Disabled unless `MUXLL_BILLING=1`. Still to come: native-Lightning top-up (CEP-8 `balance.topup`), fixed per-Mtok pricing overrides, and the CEP-8 payment wrap. See [`docs/billing.md`](docs/billing.md).
- **Pricing & quotas** — Billing is opt-in via `MUXLL_BILLING=1`: CEP-8 gates `chat.complete` against a prepaid USD ledger, caps output to the available balance, debits actual usage, and prices `balance.topup` as a Lightning payment. `admin.balance.*` remains available for operator grants. Fixed per-Mtok pricing overrides and live exchange-rate sourcing are still deferred. See [`design/billing.md`](design/billing.md).

See [AGENTS.md](AGENTS.md) for contributor/agent instructions, and
[`docs/idea.md`](docs/idea.md) for the original design note.
7 changes: 7 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const METHODS = {
adminModelsGetPolicy: "admin.models.getPolicy",
adminModelsSetPolicy: "admin.models.setPolicy",
balanceGet: "balance.get",
balanceTopup: "balance.topup",
adminBalanceGet: "admin.balance.get",
adminBalanceCredit: "admin.balance.credit",
} as const;
Expand Down Expand Up @@ -298,6 +299,11 @@ export const modelPolicySetOutput = {
// balance.get: no arguments; reads the caller's own balance + recent usage.
export const balanceGetInput = {};

// balance.topup: payment is verified by CEP-8 before the handler runs.
export const balanceTopupInput = {
amountUsd: z.number().positive(),
};

// admin.balance.get: read any caller's balance + recent usage (admin-only).
export const adminBalanceGetInput = {
pubkey: z.string(),
Expand Down Expand Up @@ -335,6 +341,7 @@ export type ListModelsResponse = z.infer<typeof modelsListSchema>;
export type AdminBalanceGetInput = z.input<
z.ZodObject<typeof adminBalanceGetInput>
>;
export type BalanceTopupInput = z.input<z.ZodObject<typeof balanceTopupInput>>;
export type AdminBalanceCreditInput = z.input<
z.ZodObject<typeof adminBalanceCreditInput>
>;
5 changes: 5 additions & 0 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,9 @@ export {
type CapInput,
type CapResult,
} from "./pricing.ts";
export {
billingPricedCapabilities,
createBillingResolvePrice,
type BillingPaymentsOptions,
} from "./payments.ts";
export type { Models } from "@earendil-works/pi-ai";
21 changes: 21 additions & 0 deletions packages/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/
import { renameSync, readFileSync, writeFileSync } from "node:fs";
import { ApplesauceRelayPool, PrivateKeySigner } from "@contextvm/sdk";
import { LnBolt11NwcPaymentProcessor } from "@contextvm/sdk/payments";
import { builtinModels } from "@earendil-works/pi-ai/providers/all";
import type { ModelPolicy, ModelPolicySet } from "@muxll/core";
import { Ledger } from "./ledger.ts";
Expand Down Expand Up @@ -95,6 +96,23 @@ async function main() {
minChargeUsd: Number(process.env.MUXLL_MIN_CHARGE_USD ?? 0),
})
: undefined;
const nwcConnection = process.env.NWC_SERVER_CONNECTION;
const usdPerSat = Number(process.env.MUXLL_USD_PER_SAT ?? 0);
if (billingEnabled && !nwcConnection) {
throw new Error("NWC_SERVER_CONNECTION is required when MUXLL_BILLING=1");
}
if (billingEnabled && (!Number.isFinite(usdPerSat) || usdPerSat <= 0)) {
throw new Error(
"MUXLL_USD_PER_SAT must be a positive finite number when billing is enabled",
);
}
const paymentProcessors = billingEnabled
? [
new LnBolt11NwcPaymentProcessor({
nwcConnectionString: nwcConnection!,
}),
]
: undefined;

const instance = await startServer({
signer,
Expand All @@ -107,6 +125,9 @@ async function main() {
onPolicyMutate: (set) => writePolicyFile(policyFile, set),
ledger,
priceBook,
paymentProcessors,
usdPerSat: billingEnabled ? usdPerSat : undefined,
topupBufferPct: Number(process.env.MUXLL_TOPUP_BUFFER_PCT ?? 0),
});

console.log("Muxll CVM server running.");
Expand Down
96 changes: 96 additions & 0 deletions packages/server/src/payments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import {
quotePrice,
rejectPrice,
waivePrice,
type PricedCapability,
type ResolvePriceFn,
} from "@contextvm/sdk/payments";
import { METHODS } from "@muxll/core";
import { Ledger } from "./ledger.ts";
import { PriceBook } from "./pricing.ts";

export interface BillingPaymentsOptions {
ledger: Ledger;
priceBook: PriceBook;
/** USD represented by one satoshi for deterministic top-up quotes. */
usdPerSat: number;
/** Optional exchange-rate safety buffer, expressed as a percentage. */
topupBufferPct?: number;
}

export const billingPricedCapabilities: readonly PricedCapability[] = [
{
method: "tools/call",
name: METHODS.chatComplete,
// chat.complete is waived or rejected by resolvePrice; this is a
// discovery fallback only and is never charged.
amount: 1,
currencyUnit: "sats",
description: "Prepaid balance authorization",
},
{
method: "tools/call",
name: METHODS.balanceTopup,
// The request-specific quote is returned by resolvePrice.
amount: 1,
currencyUnit: "sats",
description: "Top up prepaid USD balance",
},
];

function toolName(request: { params?: unknown }): string | undefined {
const params = request.params;
if (!params || typeof params !== "object") return undefined;
const name = (params as { name?: unknown }).name;
return typeof name === "string" ? name : undefined;
}

function requestedTopupUsd(request: { params?: unknown }): number | undefined {
const params = request.params;
if (!params || typeof params !== "object") return undefined;
const args = (params as { arguments?: unknown }).arguments;
if (!args || typeof args !== "object") return undefined;
const amountUsd = (args as { amountUsd?: unknown }).amountUsd;
return typeof amountUsd === "number" && Number.isFinite(amountUsd)
? amountUsd
: undefined;
}

/** CEP-8 authorization and top-up quote policy for the prepaid ledger. */
export function createBillingResolvePrice(
options: BillingPaymentsOptions,
): ResolvePriceFn {
if (!Number.isFinite(options.usdPerSat) || options.usdPerSat <= 0) {
throw new Error("usdPerSat must be a positive finite number");
}
const bufferPct = options.topupBufferPct ?? 0;
if (!Number.isFinite(bufferPct) || bufferPct < 0) {
throw new Error("topupBufferPct must be a non-negative finite number");
}

return async ({ capability, request, clientPubkey }) => {
if (capability.name === METHODS.chatComplete) {
const balanceUsd = options.ledger.balanceUsd(clientPubkey);
// A zero floor must not make an empty balance pass authorization.
const floor = Math.max(options.priceBook.minChargeUsd, Number.EPSILON);
return balanceUsd >= floor
? waivePrice()
: rejectPrice("insufficient balance - top up via balance.topup");
}

if (capability.name === METHODS.balanceTopup) {
const amountUsd = requestedTopupUsd(request);
if (amountUsd === undefined || amountUsd <= 0) {
return rejectPrice("amountUsd must be a positive number");
}
const sats = Math.ceil(
(amountUsd / options.usdPerSat) * (1 + bufferPct / 100),
);
return quotePrice(Math.max(1, sats), {
description: `Top up $${amountUsd}`,
});
}

return rejectPrice(`unsupported priced capability: ${toolName(request)}`);
};
}
7 changes: 4 additions & 3 deletions packages/server/src/pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export interface CapInput {
}

export type CapResult =
{ reject: true; reason: string } | { maxTokens: number };
{ reject: true; reason: string } | { maxTokens?: number };

/**
* Bound a request's output so the caller's balance can afford it. Returns the
Expand All @@ -105,16 +105,17 @@ export function computeOutputCap(i: CapInput): CapResult {
const cap = Math.min(
i.userMaxTokens ?? Infinity,
i.modelMaxTokens ?? Infinity,
i.contextWindow ?? Infinity,
);
return Number.isFinite(cap)
? { maxTokens: Math.max(1, Math.floor(cap)) }
: { maxTokens: 1 };
: { maxTokens: undefined };
}
const maxOut = Math.floor(budget / i.rates.outputPerToken);
if (maxOut < 1) {
return { reject: true, reason: "insufficient balance for output tokens" };
}
let cap = Math.min(
const cap = Math.min(
i.userMaxTokens ?? Infinity,
maxOut,
i.modelMaxTokens ?? Infinity,
Expand Down
65 changes: 63 additions & 2 deletions packages/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ import {
type OpenStreamWriter,
type RelayHandler,
} from "@contextvm/sdk";
import {
withServerPayments,
type PaymentInteractionPolicy,
type PaymentProcessor,
} from "@contextvm/sdk/payments";
import { clampThinkingLevel } from "@earendil-works/pi-ai";
import type {
Api,
Expand All @@ -33,6 +38,7 @@ import type {
import {
adminBalanceCreditInput,
adminBalanceGetInput,
balanceTopupInput,
balanceGetInput,
chatInput,
METHODS,
Expand All @@ -55,6 +61,10 @@ import {
} from "./wire.ts";
import { Ledger } from "./ledger.ts";
import { PriceBook, computeOutputCap } from "./pricing.ts";
import {
billingPricedCapabilities,
createBillingResolvePrice,
} from "./payments.ts";

/** Billing context threaded into a chat handler when a ledger + pricebook are configured. */
interface BillingCtx {
Expand Down Expand Up @@ -287,12 +297,35 @@ export function registerBalanceTools(
ledger: Ledger,
adminPubkeys: string[],
): void {
server.registerTool(
METHODS.balanceTopup,
{
title: "Top Up Balance",
description:
"Credit the caller's prepaid USD balance after CEP-8 verifies the Lightning payment.",
inputSchema: balanceTopupInput,
},
async (input, extra) => {
const pk = clientPubkey(extra);
if (!pk) throw new Error("cannot determine caller");
ledger.credit(pk, input.amountUsd, "lightning");
return {
content: [],
structuredContent: {
pubkey: pk,
balanceUsd: ledger.balanceUsd(pk),
creditedUsd: input.amountUsd,
},
};
},
);

server.registerTool(
METHODS.balanceGet,
{
title: "Get Balance",
description:
"Return the caller's prepaid balance (USD) and recent usage. Balances are funded via admin.balance.credit (Lightning top-up arrives in a later phase).",
"Return the caller's prepaid balance (USD) and recent usage. Balances are funded via admin.balance.credit or CEP-8 Lightning top-up.",
inputSchema: balanceGetInput,
},
async (_input, extra) => {
Expand Down Expand Up @@ -515,6 +548,14 @@ export interface StartServerOptions {
ledger?: Ledger;
/** Pricing config. Required when `ledger` is set (markup-only in Phase 1). */
priceBook?: PriceBook;
/** CEP-8 server payment rails used for balance.topup invoices. */
paymentProcessors?: readonly PaymentProcessor[];
/** USD represented by one satoshi for balance.topup quotes. */
usdPerSat?: number;
/** Optional exchange-rate safety buffer for top-up quotes. */
topupBufferPct?: number;
/** Accepted CEP-8 payment lifecycle. Defaults to the SDK's optional policy. */
paymentInteraction?: PaymentInteractionPolicy;
}

export interface StartedServer {
Expand All @@ -539,6 +580,12 @@ export async function startServer(
if (options.ledger && !options.priceBook) {
throw new Error("priceBook is required when ledger is provided");
}
if (options.ledger && !options.usdPerSat) {
throw new Error("usdPerSat is required when ledger is provided");
}
if (options.ledger && !(options.paymentProcessors?.length ?? 0)) {
throw new Error("paymentProcessors are required when ledger is provided");
}
const billingOpts =
options.ledger && options.priceBook
? { ledger: options.ledger, priceBook: options.priceBook }
Expand All @@ -549,7 +596,7 @@ export async function startServer(
registerBalanceTools(server, options.ledger, options.adminPubkeys ?? []);
}

const transport = new NostrServerTransport({
let transport = new NostrServerTransport({
signer: options.signer,
relayHandler: options.relayHandler,
serverInfo: options.serverInfo,
Expand All @@ -562,6 +609,20 @@ export async function startServer(
oversizedTransfer: { enabled: true },
});

if (billingOpts) {
transport = withServerPayments(transport, {
processors: options.paymentProcessors!,
pricedCapabilities: billingPricedCapabilities,
resolvePrice: createBillingResolvePrice({
ledger: billingOpts.ledger,
priceBook: billingOpts.priceBook,
usdPerSat: options.usdPerSat!,
topupBufferPct: options.topupBufferPct,
}),
paymentInteraction: options.paymentInteraction,
});
}

await server.connect(transport);

return {
Expand Down
Loading