@lightninglabs/pi-402 0.0.0-stage → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,104 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { type FileReceiptLog, receiptPaidSat } from "./store.ts";
3
+ import type { WalletService } from "./wallet.ts";
4
+
5
+ // registerWalletCommands wires the /wallet, /deposit, and /balance slash
6
+ // commands. They surface the wallet's funding state to the user, out of band
7
+ // from the inference path — the agent core never sees a boarding address.
8
+ export function registerWalletCommands(pi: ExtensionAPI, wallet: WalletService): void {
9
+ pi.registerCommand("wallet", {
10
+ description: "Show the pi-402 wallet status and balance",
11
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
12
+ const balance = await wallet.confirmedBalanceSat();
13
+ ctx.ui.setStatus("pi-402", `⚡ ${balance} sat`);
14
+ ctx.ui.notify(`pi-402 wallet: ${balance} sat confirmed`, "info");
15
+ },
16
+ });
17
+
18
+ pi.registerCommand("deposit", {
19
+ description: "Show an on-chain boarding address to fund the wallet",
20
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
21
+ const address = await wallet.depositAddress();
22
+ ctx.ui.notify(`Fund your pi-402 wallet by sending on-chain funds to: ${address}`, "info");
23
+ },
24
+ });
25
+
26
+ pi.registerCommand("fund", {
27
+ description: "Show a Lightning invoice to fund the wallet (/fund <sats>)",
28
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
29
+ // An on-chain deposit needs a confirmation plus an Ark round before
30
+ // it is spendable; an invoice receive settles through a swap in
31
+ // seconds, so this is the path worth reaching for when funding
32
+ // interactively.
33
+ const amountSat = parseFundAmount(args);
34
+ if (amountSat === null) {
35
+ ctx.ui.notify("Usage: /fund <sats>, for example /fund 50000", "error");
36
+ return;
37
+ }
38
+
39
+ const invoice = await wallet.receiveInvoice(amountSat);
40
+ ctx.ui.notify(`Pay this invoice to add ${amountSat} sat to your pi-402 wallet:\n\n${invoice}`, "info");
41
+ },
42
+ });
43
+
44
+ pi.registerCommand("balance", {
45
+ description: "Show the confirmed wallet balance",
46
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
47
+ const balance = await wallet.confirmedBalanceSat();
48
+ ctx.ui.notify(`Confirmed balance: ${balance} sat`, "info");
49
+ },
50
+ });
51
+ }
52
+
53
+ // parseFundAmount reads the sat amount off a /fund invocation, returning null
54
+ // when it is missing or not a positive whole number. Underscores and commas are
55
+ // tolerated, since a funding amount is exactly the kind of number people group
56
+ // the digits of.
57
+ export function parseFundAmount(args: string): number | null {
58
+ const raw = args.trim().replace(/[_,]/g, "");
59
+ if (!/^\d+$/.test(raw)) {
60
+ return null;
61
+ }
62
+
63
+ const amountSat = Number(raw);
64
+ return Number.isSafeInteger(amountSat) && amountSat > 0 ? amountSat : null;
65
+ }
66
+
67
+ // registerReceiptCommands wires the /receipts slash command over the durable
68
+ // payment ledger: today's spend, the all-time total, and the most recent
69
+ // payments with their proof-of-payment hashes.
70
+ export function registerReceiptCommands(pi: ExtensionAPI, receiptLog: FileReceiptLog): void {
71
+ pi.registerCommand("receipts", {
72
+ description: "Show L402 payment receipts and spend totals",
73
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
74
+ const receipts = await receiptLog.readAll();
75
+ if (receipts.length === 0) {
76
+ ctx.ui.notify("No payments recorded yet.", "info");
77
+ return;
78
+ }
79
+
80
+ // One read serves all three views; the day total filters the
81
+ // records already in hand.
82
+ const day = new Date().toISOString().slice(0, 10);
83
+ const totalSat = receipts.reduce((sum, r) => sum + receiptPaidSat(r), 0);
84
+ const todaySat = receipts
85
+ .filter((r) => r.timestamp?.slice(0, 10) === day)
86
+ .reduce((sum, r) => sum + receiptPaidSat(r), 0);
87
+
88
+ const recent = receipts
89
+ .slice(-10)
90
+ .reverse()
91
+ .map((r) => {
92
+ const model = r.model ?? "unknown-model";
93
+ const when = r.timestamp?.replace("T", " ").slice(0, 19) ?? "unknown-time";
94
+ return `${when} ${receiptPaidSat(r)} sat ${model} ${r.paymentHash.slice(0, 12)}…`;
95
+ })
96
+ .join("\n");
97
+
98
+ ctx.ui.notify(
99
+ `pi-402 receipts: ${receipts.length} payment(s), ${totalSat} sat all-time, ${todaySat} sat today\n${recent}`,
100
+ "info",
101
+ );
102
+ },
103
+ });
104
+ }
package/src/index.ts ADDED
@@ -0,0 +1,138 @@
1
+ import { join } from "node:path";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { registerReceiptCommands, registerWalletCommands } from "./commands.ts";
4
+ import { MockInvoicePayer, WalletInvoicePayer } from "./payer.ts";
5
+ import { buildBudgetPolicy } from "./policy.ts";
6
+ import { makeStreamSimple } from "./provider.ts";
7
+ import { dataDir, FileCredentialStore, FileReceiptLog, FileTokenStore } from "./store.ts";
8
+ import { WalletService } from "./wallet.ts";
9
+
10
+ // PROVIDER_ID is the model provider this extension registers. Users select it
11
+ // with `/model l402-baseten/<model>`.
12
+ const PROVIDER_ID = "l402-baseten";
13
+
14
+ // DEFAULT_PROXY_URL is the local L402 proxy used when L402_PROXY_URL is unset.
15
+ const DEFAULT_PROXY_URL = "http://localhost:8402";
16
+
17
+ // The extension entry point. Pi calls this with the ExtensionAPI when the
18
+ // extension is loaded. Everything the extension does is registered here; Pi core
19
+ // is never modified.
20
+ export default function (pi: ExtensionAPI): void {
21
+ const proxyUrl = process.env.L402_PROXY_URL ?? DEFAULT_PROXY_URL;
22
+
23
+ // The wallet holds the embedded walletdk instance for the whole session.
24
+ const wallet = new WalletService();
25
+
26
+ // The mock payer exercises the buyer loop end to end against a mock proxy
27
+ // with no Lightning at all; the real payer settles through walletdk.
28
+ const mockMode = process.env.PI402_MOCK_PAYER === "1";
29
+ const payer = mockMode ? new MockInvoicePayer() : new WalletInvoicePayer(wallet);
30
+
31
+ // Durable state: paid tokens and the receipt ledger live under the data
32
+ // dir, so a paid bundle survives a restart instead of being re-purchased
33
+ // and every spend leaves an auditable record. Mock mode stays in-memory,
34
+ // keeping fake spends out of the real ledger.
35
+ const stateDir = dataDir();
36
+ const tokenStore = mockMode ? undefined : new FileTokenStore(join(stateDir, "tokens.json"));
37
+ const credentialStore = mockMode ? undefined : new FileCredentialStore(join(stateDir, "credentials.json"));
38
+ const receiptLog = mockMode ? undefined : new FileReceiptLog(join(stateDir, "receipts.ndjson"));
39
+
40
+ // The daily cap is seeded from the ledger, so a restart mid-day cannot
41
+ // reset the day's spend to zero.
42
+ const policy = buildBudgetPolicy(pi, receiptLog);
43
+
44
+ // Bootstrap the wallet once per session. In mock mode there is no wallet
45
+ // to load, so the session starts without touching the WASM at all.
46
+ if (!mockMode) {
47
+ pi.on("session_start", async () => {
48
+ await wallet.startAndUnlock();
49
+ });
50
+
51
+ // Release the wallet when Pi tears the extension down. Without this
52
+ // the WASM runtime and its SQLite worker thread keep Node's event
53
+ // loop alive, and a non-interactive `pi -p` never exits.
54
+ //
55
+ // Only a quit stops it. Pi also emits session_shutdown when it replaces
56
+ // the session (reload, new, resume, fork) and keeps running, and the
57
+ // next session needs the wallet the SQLite bridge belongs to.
58
+ pi.on("session_shutdown", async (event) => {
59
+ if (event.reason === "quit") {
60
+ await wallet.stop();
61
+ }
62
+ });
63
+ }
64
+
65
+ // Register the paid model provider. Its streamSimple owns the request path,
66
+ // so the paying fetch is injected without any change to Pi core.
67
+ pi.registerProvider(PROVIDER_ID, {
68
+ api: "l402-openai",
69
+ baseUrl: proxyUrl,
70
+ // The proxy holds the real model credential; Pi sends no API key.
71
+ apiKey: "unused",
72
+ models: [
73
+ {
74
+ // Maps to deepseek-ai/DeepSeek-V4-Flash-0731 on the wire.
75
+ // The budget option: its bundle quotes at roughly half a
76
+ // GLM-4.7 bundle, so it is also the cheapest way to
77
+ // exercise the paid path end to end.
78
+ id: "deepseek-v4-flash",
79
+ name: "DeepSeek V4 Flash (L402)",
80
+ input: ["text"],
81
+ reasoning: true,
82
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
83
+ contextWindow: 1_000_000,
84
+ maxTokens: 32_768,
85
+ },
86
+ {
87
+ // The Pi-facing id is slash-free; the provider maps it to
88
+ // the Baseten Model APIs slug (zai-org/GLM-4.7) on the wire.
89
+ id: "glm-4.7",
90
+ name: "GLM 4.7 (L402)",
91
+ input: ["text"],
92
+ reasoning: true,
93
+ // Cost is metered in sats at the proxy, not billed per token here.
94
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
95
+ contextWindow: 200_000,
96
+ maxTokens: 8_192,
97
+ },
98
+ {
99
+ // Maps to zai-org/GLM-5.2 on the wire.
100
+ id: "glm-5.2",
101
+ name: "GLM 5.2 (L402)",
102
+ input: ["text"],
103
+ reasoning: true,
104
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
105
+ contextWindow: 200_000,
106
+ maxTokens: 8_192,
107
+ },
108
+ {
109
+ // Maps to moonshotai/Kimi-K3 on the wire. Natively
110
+ // multimodal with a 1M-token context.
111
+ id: "kimi-k3",
112
+ name: "Kimi K3 (L402)",
113
+ input: ["text", "image"],
114
+ reasoning: true,
115
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
116
+ contextWindow: 1_048_576,
117
+ maxTokens: 32_768,
118
+ },
119
+ {
120
+ // Maps to moonshotai/Kimi-K3.1-Pro on the wire. 1M-token
121
+ // context, priced below Kimi K3 upstream.
122
+ id: "kimi-k3.1-pro",
123
+ name: "Kimi K3.1 Pro (L402)",
124
+ input: ["text"],
125
+ reasoning: true,
126
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
127
+ contextWindow: 1_000_000,
128
+ maxTokens: 32_768,
129
+ },
130
+ ],
131
+ streamSimple: makeStreamSimple({ payer, policy, tokenStore, credentialStore, receiptLog }),
132
+ });
133
+
134
+ registerWalletCommands(pi, wallet);
135
+ if (receiptLog) {
136
+ registerReceiptCommands(pi, receiptLog);
137
+ }
138
+ }
package/src/payer.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { InvoicePayer } from "@lightninglabs/aperture-core";
2
+ import type { WalletService } from "./wallet.ts";
3
+
4
+ // PayInvoiceInput is the L402 buyer loop's request to pay a challenge invoice.
5
+ interface PayInvoiceInput {
6
+ invoice: string;
7
+ amountSat: number;
8
+ challengeId: string;
9
+ }
10
+
11
+ // MockInvoicePayer returns a fixed preimage without paying anything. It exists
12
+ // so the buyer loop can be exercised end to end with no wallet and no
13
+ // Lightning at all.
14
+ export class MockInvoicePayer implements InvoicePayer {
15
+ async payInvoice(input: PayInvoiceInput): Promise<{ preimageHex: string; amountPaidSat: number }> {
16
+ return { preimageHex: "00".repeat(32), amountPaidSat: input.amountSat };
17
+ }
18
+ }
19
+
20
+ // WalletInvoicePayer pays an L402 invoice through the embedded walletdk wallet.
21
+ // walletdk hides the Ark↔Lightning swap: prepareSend quotes it (surfacing the
22
+ // swap fee for the budget check), sendPrepared dispatches it, and the preimage
23
+ // is read off the settled activity entry once the swap completes.
24
+ export class WalletInvoicePayer implements InvoicePayer {
25
+ private readonly wallet: WalletService;
26
+
27
+ constructor(wallet: WalletService) {
28
+ this.wallet = wallet;
29
+ }
30
+
31
+ async payInvoice(input: PayInvoiceInput): Promise<{ preimageHex: string; amountPaidSat: number }> {
32
+ return this.wallet.payInvoice(input.invoice, input.amountSat);
33
+ }
34
+ }
package/src/policy.ts ADDED
@@ -0,0 +1,209 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { BuyerPolicy } from "@lightninglabs/aperture-core";
3
+
4
+ // DEFAULT_MAX_SAT_PER_REQUEST caps a single L402 payment when
5
+ // PI402_MAX_SAT_PER_REQUEST is unset.
6
+ const DEFAULT_MAX_SAT_PER_REQUEST = 1_000;
7
+
8
+ // DEFAULT_DAILY_CAP_SAT caps cumulative daily spend when PI402_DAILY_CAP_SAT is
9
+ // unset.
10
+ const DEFAULT_DAILY_CAP_SAT = 100_000;
11
+
12
+ // readSatCeiling parses a positive-integer sat ceiling from an environment
13
+ // variable. A missing value yields the default. A present but non-numeric or
14
+ // non-positive value throws, so a typo like "1,000" fails loudly instead of
15
+ // silently disabling the cap (Number("1,000") is NaN, and every comparison with
16
+ // NaN is false, which would wave all spending through).
17
+ export function readSatCeiling(name: string, fallback: number): number {
18
+ const raw = process.env[name];
19
+ if (raw === undefined || raw === "") {
20
+ return fallback;
21
+ }
22
+ const value = Number(raw);
23
+ if (!Number.isFinite(value) || value <= 0) {
24
+ throw new Error(`${name} must be a positive number, got ${JSON.stringify(raw)}`);
25
+ }
26
+ return value;
27
+ }
28
+
29
+ // maxSatPerRequest returns the per-request spend ceiling from the environment.
30
+ // The wallet payer reads the same ceiling to assert the quoted total outflow
31
+ // before dispatching a send.
32
+ export function maxSatPerRequest(): number {
33
+ return readSatCeiling("PI402_MAX_SAT_PER_REQUEST", DEFAULT_MAX_SAT_PER_REQUEST);
34
+ }
35
+
36
+ // PaymentScheme selects which HTTP authentication schemes the buyer will
37
+ // answer a 402 with.
38
+ export type PaymentScheme = "auto" | "l402";
39
+
40
+ // paymentScheme reads the scheme preference from PI402_PAYMENT_SCHEME.
41
+ //
42
+ // The default is "auto": take a Payment (MPP) offer when the seller makes
43
+ // one, and fall back to L402 otherwise. Aperture with --enablempp offers both
44
+ // in a single merged WWW-Authenticate, so "auto" is what exercises MPP.
45
+ // Setting "l402" pins the buyer to the older scheme, which is the escape
46
+ // hatch if a seller's Payment offer turns out to be unsatisfiable.
47
+ //
48
+ // An unrecognized value throws rather than silently falling back. A typo in
49
+ // the scheme name should not quietly change which protocol the money moves
50
+ // over.
51
+ export function paymentScheme(): PaymentScheme {
52
+ const raw = process.env.PI402_PAYMENT_SCHEME;
53
+ if (raw === undefined || raw === "") {
54
+ return "auto";
55
+ }
56
+
57
+ const value = raw.trim().toLowerCase();
58
+ if (value === "auto" || value === "l402") {
59
+ return value;
60
+ }
61
+
62
+ throw new Error(`PI402_PAYMENT_SCHEME must be "auto" or "l402", got ${JSON.stringify(raw)}`);
63
+ }
64
+
65
+ // mppIntents returns the Payment intents to accept, most wanted first.
66
+ //
67
+ // Under "l402" the list is empty, so no Payment offer matches and the buyer
68
+ // takes the L402 door. Otherwise the default is "charge" alone: a session
69
+ // holds a deposit the seller owes back, and reclaiming it needs a refund
70
+ // invoice the wallet must mint and a close the process must remember to
71
+ // send. Charge settles each request on its own and leaves nothing
72
+ // outstanding, which is the right default for a wallet running unattended
73
+ // inside an editor.
74
+ //
75
+ // PI402_MPP_INTENTS opts into more, as a comma-separated preference list
76
+ // drawn from "session" and "charge".
77
+ //
78
+ // The session intent additionally requires a return-invoice minter. A
79
+ // session's unspent deposit comes back through an amountless refund invoice
80
+ // the buyer must supply at open, and the reference server refuses an open
81
+ // without one, so a buyer with no minter would pay deposits whose opens are
82
+ // guaranteed to be refused. The swap-backed embedded wallet cannot mint
83
+ // amountless invoices (the daemon refuses a zero amount), so the extension
84
+ // has no minter today and sessions stay off there; the dev harness can wire
85
+ // one from a local lnd, which is what makes the regtest session flow
86
+ // testable. A wavelength-side amountless receive is the upstream seam that
87
+ // lifts this for the extension.
88
+ export function mppIntents(options?: { hasReturnInvoiceMinter?: boolean }): readonly string[] {
89
+ if (paymentScheme() === "l402") {
90
+ return [];
91
+ }
92
+
93
+ const raw = process.env.PI402_MPP_INTENTS;
94
+ if (raw === undefined || raw === "") {
95
+ return ["charge"];
96
+ }
97
+
98
+ const intents = raw
99
+ .split(",")
100
+ .map((intent) => intent.trim().toLowerCase())
101
+ .filter((intent) => intent !== "");
102
+
103
+ for (const intent of intents) {
104
+ if (intent !== "charge" && intent !== "session") {
105
+ throw new Error(`PI402_MPP_INTENTS entries must be "charge" or "session", got ${JSON.stringify(intent)}`);
106
+ }
107
+ }
108
+ if (intents.length === 0) {
109
+ throw new Error("PI402_MPP_INTENTS must name at least one intent when set");
110
+ }
111
+
112
+ if (intents.includes("session") && !options?.hasReturnInvoiceMinter) {
113
+ throw new Error(
114
+ "PI402_MPP_INTENTS includes sessions, but no return-invoice minter is available: a session's " +
115
+ "refund needs an amountless invoice the embedded wallet cannot mint, and the server refuses " +
116
+ "an open without one, so the deposit would be paid for a guaranteed refusal",
117
+ );
118
+ }
119
+
120
+ return intents;
121
+ }
122
+
123
+ // utcDayKey returns a stable key for the current UTC calendar day, used to reset
124
+ // the daily counter at the day boundary.
125
+ function utcDayKey(now: Date): string {
126
+ return now.toISOString().slice(0, 10);
127
+ }
128
+
129
+ // SpentTodaySource supplies the sats already spent on the current UTC day,
130
+ // typically summed from the durable receipt ledger.
131
+ export interface SpentTodaySource {
132
+ spentTodaySat(): Promise<number>;
133
+ }
134
+
135
+ // buildBudgetPolicy constructs the buyer policy from environment ceilings. The
136
+ // policy is asserted before any wallet action, on the quoted total outflow
137
+ // (invoice principal plus swap fee), so a runaway session cannot drain the
138
+ // wallet.
139
+ //
140
+ // With a spentToday source, the ledger is read fresh on every assertion and
141
+ // combined with the in-memory counter, so neither a restart nor a concurrent
142
+ // session can reset the day's spend to zero.
143
+ export function buildBudgetPolicy(_pi: ExtensionAPI, spentTodaySource?: SpentTodaySource): BuyerPolicy {
144
+ const perRequestCap = maxSatPerRequest();
145
+ const dailyCapSat = readSatCeiling("PI402_DAILY_CAP_SAT", DEFAULT_DAILY_CAP_SAT);
146
+
147
+ // spentToday tracks cumulative spend for the daily ceiling, reset at the UTC
148
+ // day boundary.
149
+ let spentToday = 0;
150
+ let currentDay = utcDayKey(new Date());
151
+
152
+ return {
153
+ async assertAllowed(ctx: { amountSat: number; host: string; url: string; routeId?: string }): Promise<void> {
154
+ // Read the ledger fresh on every assertion: it is the shared
155
+ // ground truth across restarts and concurrent sessions. Payments
156
+ // are rare and the file is small, so the read is cheap. An
157
+ // unreadable ledger degrades to the in-memory counter alone; the
158
+ // degradation is logged because a spend cap silently losing its
159
+ // memory should never be invisible.
160
+ let ledgerToday = 0;
161
+ if (spentTodaySource) {
162
+ try {
163
+ ledgerToday = await spentTodaySource.spentTodaySat();
164
+ } catch (err) {
165
+ console.warn("pi-402: receipt ledger unreadable, daily cap using in-memory spend only:", err);
166
+ }
167
+ }
168
+
169
+ // Everything below is synchronous, so concurrent assertions in
170
+ // this process cannot interleave between the check and the
171
+ // counter update.
172
+ const day = utcDayKey(new Date());
173
+ if (day !== currentDay) {
174
+ currentDay = day;
175
+ spentToday = 0;
176
+ }
177
+
178
+ if (ctx.amountSat > perRequestCap) {
179
+ throw new Error(`payment ${ctx.amountSat} sat exceeds per-request cap ${perRequestCap} sat`);
180
+ }
181
+
182
+ // The in-memory counter carries in-flight amounts the ledger
183
+ // cannot see yet (and quotes for payments that later failed);
184
+ // the ledger carries settled spends from other sessions this
185
+ // counter never saw. Take the max: it is conservative under
186
+ // either gap. Units differ slightly, quoted face value in the
187
+ // counter vs fee-inclusive paidSat in the ledger, which also
188
+ // errs on the conservative side.
189
+ //
190
+ // The residual overshoot bound across sessions is the sum of
191
+ // all in-flight (asserted, not yet settled) amounts in OTHER
192
+ // processes, each bounded by their per-request cap; unsettled
193
+ // spends are invisible to this process until they reach the
194
+ // ledger.
195
+ const effectiveSpent = Math.max(spentToday, ledgerToday);
196
+ if (effectiveSpent + ctx.amountSat > dailyCapSat) {
197
+ throw new Error(`payment would exceed daily cap ${dailyCapSat} sat`);
198
+ }
199
+
200
+ // NOTE: the counter is incremented here, before the wallet actually
201
+ // pays. The paying fetch calls assertAllowed ahead of settlement and
202
+ // exposes no post-payment callback, so a failed payment is not rolled
203
+ // back off the daily counter. This errs conservative (a failed
204
+ // payment still consumes budget), which is the safe direction for a
205
+ // spend cap; a clean rollback needs a hook the library does not offer.
206
+ spentToday = effectiveSpent + ctx.amountSat;
207
+ },
208
+ };
209
+ }