@lightninglabs/pi-402 0.0.0-stage → 0.0.1

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,567 @@
1
+ import {
2
+ type Api,
3
+ type AssistantMessage,
4
+ type AssistantMessageEventStream,
5
+ type Context,
6
+ createAssistantMessageEventStream,
7
+ type Model,
8
+ type SimpleStreamOptions,
9
+ } from "@earendil-works/pi-ai";
10
+ import {
11
+ type ClientReceipt,
12
+ type CredentialStore,
13
+ createPayingFetch,
14
+ TokenCache,
15
+ type TokenStore,
16
+ } from "@lightninglabs/aperture-client";
17
+ import type { BuyerPolicy, InvoicePayer } from "@lightninglabs/aperture-core";
18
+ import OpenAI from "openai";
19
+ import { mppIntents } from "./policy.ts";
20
+ import type { ReceiptRecord } from "./store.ts";
21
+
22
+ export interface StreamDeps {
23
+ payer: InvoicePayer;
24
+ policy: BuyerPolicy;
25
+
26
+ // tokenStore holds paid L402 tokens. A durable store means paid bundles
27
+ // survive process restarts; when absent, tokens live in memory and die
28
+ // with the process.
29
+ tokenStore?: TokenStore;
30
+
31
+ // credentialStore holds paid Payment credentials, the MPP counterpart to
32
+ // tokenStore. Durable storage keeps a retry that throws after the money
33
+ // moved from turning into a second payment on the next attempt.
34
+ credentialStore?: CredentialStore;
35
+
36
+ // receiptLog receives one record per settled payment, stamped with the
37
+ // model the payment bought inference for. Absent in mock mode, so fake
38
+ // spends never pollute the real ledger.
39
+ receiptLog?: { store(receipt: ReceiptRecord): Promise<void> | void };
40
+
41
+ // returnInvoiceMinter mints the amountless refund invoice a session
42
+ // deposit needs at open. The embedded wallet cannot provide one, so the
43
+ // extension leaves this unset and the session intent stays refused; the
44
+ // dev harness wires one from a local lnd to exercise the session flow
45
+ // on regtest.
46
+ returnInvoiceMinter?: {
47
+ mintAmountlessInvoice(input: { origin: string; realm: string; memo?: string }): Promise<{ invoice: string }>;
48
+ };
49
+ }
50
+
51
+ // BASETEN_MODEL_SLUGS maps the Pi-facing model ids (kept slash-free so they
52
+ // compose with Pi's provider/model selection) to the full Baseten Model APIs
53
+ // slugs sent on the wire.
54
+ const BASETEN_MODEL_SLUGS: Record<string, string> = {
55
+ "deepseek-v4-flash": "deepseek-ai/DeepSeek-V4-Flash-0731",
56
+ "glm-4.7": "zai-org/GLM-4.7",
57
+ "glm-5": "zai-org/GLM-5",
58
+ "glm-5.2": "zai-org/GLM-5.2",
59
+ "kimi-k3": "moonshotai/Kimi-K3",
60
+ };
61
+
62
+ // wireModelId resolves the model id sent to the proxy/upstream.
63
+ function wireModelId(id: string): string {
64
+ return BASETEN_MODEL_SLUGS[id] ?? id;
65
+ }
66
+
67
+ // requestUrl extracts the URL from any of the shapes fetch accepts, without
68
+ // consuming the request body.
69
+ function requestUrl(input: RequestInfo | URL): string {
70
+ if (typeof input === "string") {
71
+ return input;
72
+ }
73
+ if (input instanceof URL) {
74
+ return input.href;
75
+ }
76
+ return input.url;
77
+ }
78
+
79
+ // tokenRouteId scopes a cached L402 token to one model.
80
+ //
81
+ // The token cache keys on origin + routeId + method, and routeId defaults to
82
+ // the request path. Every model this provider registers posts to the same
83
+ // /v1/chat/completions path, so without the model in the key they all share
84
+ // one cache slot: paying for model B would overwrite the token for model A and
85
+ // strand A's unspent bundle balance, since the macaroon and preimage needed to
86
+ // present it are gone. The seller already refuses to spend one model's bundle
87
+ // on another, so the buyer must track them separately too.
88
+ export function tokenRouteId(input: RequestInfo | URL, model: string): string {
89
+ let path: string;
90
+ try {
91
+ path = new URL(requestUrl(input)).pathname;
92
+ } catch {
93
+ // A URL we cannot parse still needs a stable key; the model alone
94
+ // keeps distinct models from sharing a token.
95
+ path = "";
96
+ }
97
+ return `${path}|${model}`;
98
+ }
99
+
100
+ // makeStreamSimple builds the paid provider's streamSimple. The returned
101
+ // function owns the entire request path to the L402 proxy: it constructs an
102
+ // OpenAI client whose fetch is a paying fetch, so 402 challenges are paid and
103
+ // retried transparently, then translates the OpenAI SSE stream into Pi's event
104
+ // stream.
105
+ export function makeStreamSimple(deps: StreamDeps) {
106
+ // One token cache per provider instance, so paid L402 tokens are reused
107
+ // across calls until they expire. With a durable store injected, reuse
108
+ // extends across processes too.
109
+ const tokenCache = deps.tokenStore ?? new TokenCache();
110
+
111
+ return function streamSimple(
112
+ model: Model<Api>,
113
+ context: Context,
114
+ options?: SimpleStreamOptions,
115
+ ): AssistantMessageEventStream {
116
+ const stream = createAssistantMessageEventStream();
117
+
118
+ (async () => {
119
+ const output = emptyAssistantMessage(model);
120
+
121
+ try {
122
+ // The receipt adapter stamps each settled payment with the
123
+ // model it bought inference for. It is built per call, so
124
+ // the model rides in via closure without threading through
125
+ // the buyer loop.
126
+ const receiptLog = deps.receiptLog;
127
+ const receiptCache = receiptLog
128
+ ? {
129
+ store: (receipt: ClientReceipt) => receiptLog.store({ ...receipt, model: wireModelId(model.id) }),
130
+ }
131
+ : undefined;
132
+
133
+ // The paying fetch runs the buyer loop: detect 402, assert the
134
+ // budget policy, pay via the wallet, cache the credential, and
135
+ // retry. Which scheme it answers with is the seller's choice
136
+ // as narrowed by ours: aperture with --enablempp offers a
137
+ // Payment challenge alongside L402 in one merged header, and
138
+ // mppIntents decides whether we take it.
139
+ const payingFetch = createPayingFetch({
140
+ payer: deps.payer,
141
+ buyerPolicy: deps.policy,
142
+ tokenCache,
143
+ receiptCache,
144
+ mpp: {
145
+ // The session intent is only usable with a minter for
146
+ // the amountless refund invoice an open must carry;
147
+ // mppIntents refuses it otherwise, so a deposit is
148
+ // never paid for an open the server is guaranteed to
149
+ // refuse.
150
+ intents: mppIntents({
151
+ hasReturnInvoiceMinter: deps.returnInvoiceMinter !== undefined,
152
+ }),
153
+ credentialCache: deps.credentialStore,
154
+ returnInvoiceMinter: deps.returnInvoiceMinter,
155
+ onReceiptMismatch: ({ receipt, reasons }) => {
156
+ console.warn(
157
+ `pi-402: payment receipt ${receipt.reference} disagrees with what was paid for: ${reasons.join("; ")}`,
158
+ );
159
+ },
160
+ },
161
+ });
162
+
163
+ const wireModel = wireModelId(model.id);
164
+
165
+ const client = new OpenAI({
166
+ baseURL: model.baseUrl,
167
+ apiKey: "unused",
168
+ // Scope the cached token to this model, so one model's
169
+ // paid bundle is never overwritten by another's payment.
170
+ fetch: ((input: RequestInfo | URL, init?: RequestInit) =>
171
+ payingFetch(input, {
172
+ ...init,
173
+ routeId: tokenRouteId(input, wireModel),
174
+ })) as typeof fetch,
175
+ // The paying fetch owns retry semantics: it runs the L402
176
+ // buyer loop and retries exactly once after payment. The
177
+ // SDK's default of two retries would re-enter payingFetch on
178
+ // any thrown error, and a throw after payment was dispatched
179
+ // (e.g. a preimage-wait timeout) would fetch a fresh 402 and
180
+ // pay a second time. Disable SDK retries so we never
181
+ // double-pay.
182
+ maxRetries: 0,
183
+ });
184
+
185
+ const tools = toOpenAiTools(context);
186
+
187
+ const sse = await client.chat.completions.create(
188
+ {
189
+ model: wireModelId(model.id),
190
+ messages: toOpenAiMessages(context),
191
+ ...(tools ? { tools } : {}),
192
+ max_tokens: options?.maxTokens ?? Math.floor(model.maxTokens / 3),
193
+ stream: true,
194
+ // Baseten defaults include_usage to true, but ask
195
+ // explicitly: the final chunk's usage is what the
196
+ // seller-side metering debits, so both sides should
197
+ // see the same numbers.
198
+ stream_options: { include_usage: true },
199
+ },
200
+ { signal: options?.signal },
201
+ );
202
+
203
+ stream.push({ type: "start", partial: output });
204
+
205
+ const blocks = new BlockWriter(output, stream);
206
+
207
+ for await (const chunk of sse) {
208
+ const choice = chunk.choices[0];
209
+ const delta = choice?.delta as OpenAiDelta | undefined;
210
+
211
+ // Reasoning arrives first and, on a thinking model, can
212
+ // run for most of the response. Surfacing it as thinking
213
+ // content is what keeps the turn from looking hung while
214
+ // the model works.
215
+ if (delta?.reasoning_content) {
216
+ blocks.appendThinking(delta.reasoning_content);
217
+ }
218
+
219
+ if (delta?.content) {
220
+ blocks.appendText(delta.content);
221
+ }
222
+
223
+ for (const call of delta?.tool_calls ?? []) {
224
+ blocks.appendToolCall(call);
225
+ }
226
+
227
+ const finish = choice?.finish_reason;
228
+ if (finish === "length") {
229
+ output.stopReason = "length";
230
+ } else if (finish === "tool_calls") {
231
+ output.stopReason = "toolUse";
232
+ }
233
+
234
+ // Usage arrives on the final chunk (and, on Baseten,
235
+ // as running totals on every chunk); the last write
236
+ // wins either way.
237
+ if (chunk.usage) {
238
+ output.usage.input = chunk.usage.prompt_tokens ?? 0;
239
+ output.usage.output = chunk.usage.completion_tokens ?? 0;
240
+ output.usage.totalTokens = chunk.usage.total_tokens ?? 0;
241
+
242
+ const reasoning = (chunk.usage as { completion_tokens_details?: { reasoning_tokens?: number } })
243
+ .completion_tokens_details?.reasoning_tokens;
244
+ if (typeof reasoning === "number") {
245
+ output.usage.reasoning = reasoning;
246
+ }
247
+ }
248
+ }
249
+
250
+ blocks.close();
251
+
252
+ // A model can emit tool calls without the upstream setting the
253
+ // matching finish_reason. Pi routes on the stop reason, so
254
+ // derive it from what actually arrived rather than trusting
255
+ // the flag: a missed toolUse ends the turn holding calls
256
+ // nobody runs.
257
+ if (output.stopReason !== "toolUse" && output.content.some((part) => part.type === "toolCall")) {
258
+ output.stopReason = "toolUse";
259
+ }
260
+
261
+ stream.push({
262
+ type: "done",
263
+ reason: output.stopReason as "stop" | "length" | "toolUse",
264
+ message: output,
265
+ });
266
+ stream.end();
267
+ } catch (error) {
268
+ output.stopReason = options?.signal?.aborted ? "aborted" : "error";
269
+ // The OpenAI SDK wraps transport-level throws (including
270
+ // budget-policy rejections from the paying fetch) in a
271
+ // generic connection error; the cause carries the real story.
272
+ const cause = error instanceof Error && error.cause instanceof Error ? error.cause : undefined;
273
+ output.errorMessage = cause?.message ?? (error instanceof Error ? error.message : String(error));
274
+ stream.push({
275
+ type: "error",
276
+ reason: output.stopReason as "error" | "aborted",
277
+ error: output,
278
+ });
279
+ stream.end();
280
+ }
281
+ })();
282
+
283
+ return stream;
284
+ };
285
+ }
286
+
287
+ // emptyAssistantMessage seeds the output message the stream accumulates into.
288
+ function emptyAssistantMessage(model: Model<Api>): AssistantMessage {
289
+ return {
290
+ role: "assistant",
291
+ content: [],
292
+ api: model.api,
293
+ provider: model.provider,
294
+ model: model.id,
295
+ usage: {
296
+ input: 0,
297
+ output: 0,
298
+ cacheRead: 0,
299
+ cacheWrite: 0,
300
+ totalTokens: 0,
301
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
302
+ },
303
+ stopReason: "stop",
304
+ timestamp: Date.now(),
305
+ };
306
+ }
307
+
308
+ // toOpenAiTools projects Pi's tool declarations into the OpenAI function-tool
309
+ // schema. Declaring the tools matters beyond enabling tool use: Pi's system
310
+ // prompt tells the model it has tools regardless, so a model that is not given
311
+ // them in the request still tries to call them, and emits its own native
312
+ // tool-call syntax as ordinary assistant text. That markup is what the user
313
+ // ends up reading. Sending the declarations is what moves those calls onto the
314
+ // structured tool_calls channel where they can be translated.
315
+ export function toOpenAiTools(context: Context): OpenAI.Chat.Completions.ChatCompletionTool[] | undefined {
316
+ if (!context.tools?.length) {
317
+ return undefined;
318
+ }
319
+ return context.tools.map((tool) => ({
320
+ type: "function" as const,
321
+ function: {
322
+ name: tool.name,
323
+ description: tool.description,
324
+ parameters: tool.parameters as unknown as Record<string, unknown>,
325
+ },
326
+ }));
327
+ }
328
+
329
+ // textOf flattens a Pi message content array down to its text parts.
330
+ function textOf(content: unknown): string {
331
+ if (typeof content === "string") {
332
+ return content;
333
+ }
334
+ if (!Array.isArray(content)) {
335
+ return "";
336
+ }
337
+ return content
338
+ .filter((part) => (part as { type?: string })?.type === "text")
339
+ .map((part) => (part as { text: string }).text)
340
+ .join("");
341
+ }
342
+
343
+ // toOpenAiMessages converts Pi's context into OpenAI chat messages, including
344
+ // the tool-call round trip: an assistant turn that called tools has to be
345
+ // replayed with those calls attached, and each result has to come back as a
346
+ // tool message naming the call it answers. Dropping either half leaves the
347
+ // model looking at a request whose tool results answer nothing, which it
348
+ // resolves by calling the same tool again.
349
+ export function toOpenAiMessages(context: Context): OpenAI.Chat.Completions.ChatCompletionMessageParam[] {
350
+ const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [];
351
+ if (context.systemPrompt) {
352
+ messages.push({ role: "system", content: context.systemPrompt });
353
+ }
354
+
355
+ for (const msg of context.messages) {
356
+ if (msg.role === "user") {
357
+ const content = textOf(msg.content);
358
+ if (content.trim()) {
359
+ messages.push({ role: "user", content });
360
+ }
361
+ continue;
362
+ }
363
+
364
+ if (msg.role === "assistant") {
365
+ const content = textOf(msg.content);
366
+ const toolCalls = (Array.isArray(msg.content) ? msg.content : [])
367
+ .filter((part) => (part as { type?: string })?.type === "toolCall")
368
+ .map((part) => {
369
+ const call = part as { id: string; name: string; arguments: Record<string, unknown> };
370
+ return {
371
+ id: call.id,
372
+ type: "function" as const,
373
+ function: {
374
+ name: call.name,
375
+ arguments: JSON.stringify(call.arguments ?? {}),
376
+ },
377
+ };
378
+ });
379
+
380
+ // An assistant turn with neither text nor calls carries nothing
381
+ // the model can use, and an empty turn is rejected outright by
382
+ // some upstreams.
383
+ if (!content.trim() && toolCalls.length === 0) {
384
+ continue;
385
+ }
386
+
387
+ messages.push({
388
+ role: "assistant",
389
+ content: content || null,
390
+ ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
391
+ } as OpenAI.Chat.Completions.ChatCompletionAssistantMessageParam);
392
+ continue;
393
+ }
394
+
395
+ if (msg.role === "toolResult") {
396
+ messages.push({
397
+ role: "tool",
398
+ tool_call_id: msg.toolCallId,
399
+ content: textOf(msg.content) || (msg.isError ? "error" : ""),
400
+ });
401
+ }
402
+ }
403
+
404
+ return messages;
405
+ }
406
+
407
+ // OpenAiDelta is the streaming delta this provider reads. The SDK's own type
408
+ // omits reasoning_content, which is where thinking models put the bulk of a
409
+ // response.
410
+ interface OpenAiDelta {
411
+ content?: string | null;
412
+ reasoning_content?: string | null;
413
+ tool_calls?: Array<{
414
+ index: number;
415
+ id?: string;
416
+ function?: { name?: string; arguments?: string };
417
+ }>;
418
+ }
419
+
420
+ // BlockWriter turns a flat run of streaming deltas into Pi's content blocks.
421
+ //
422
+ // Pi's event protocol is bracketed: every block is opened, filled, and closed,
423
+ // and the index in each event has to address the block it belongs to. A
424
+ // response interleaves reasoning, text, and tool calls, so this tracks which
425
+ // block is currently open and closes it before opening the next kind. Tool
426
+ // call arguments are the awkward part: they arrive as JSON fragments across
427
+ // many chunks and are only parseable once the whole run has landed, so the raw
428
+ // text is buffered and parsed at close.
429
+ class BlockWriter {
430
+ private readonly output: AssistantMessage;
431
+ private readonly stream: AssistantMessageEventStream;
432
+
433
+ // open is the block currently accepting deltas, if any.
434
+ private open: { kind: "text" | "thinking" | "toolCall"; index: number } | undefined;
435
+
436
+ // rawArguments buffers each tool call's argument fragments, keyed by the
437
+ // content index of its block.
438
+ private readonly rawArguments = new Map<number, string>();
439
+
440
+ // toolIndexToContent maps the upstream tool_calls[].index onto the content
441
+ // block holding that call, so later fragments reach the right one.
442
+ private readonly toolIndexToContent = new Map<number, number>();
443
+
444
+ constructor(output: AssistantMessage, stream: AssistantMessageEventStream) {
445
+ this.output = output;
446
+ this.stream = stream;
447
+ }
448
+
449
+ appendText(delta: string): void {
450
+ const index = this.ensure("text", () => ({ type: "text", text: "" }));
451
+ (this.output.content[index] as { text: string }).text += delta;
452
+ this.stream.push({ type: "text_delta", contentIndex: index, delta, partial: this.output });
453
+ }
454
+
455
+ appendThinking(delta: string): void {
456
+ const index = this.ensure("thinking", () => ({ type: "thinking", thinking: "" }));
457
+ (this.output.content[index] as { thinking: string }).thinking += delta;
458
+ this.stream.push({ type: "thinking_delta", contentIndex: index, delta, partial: this.output });
459
+ }
460
+
461
+ appendToolCall(call: { index: number; id?: string; function?: { name?: string; arguments?: string } }): void {
462
+ let contentIndex = this.toolIndexToContent.get(call.index);
463
+
464
+ if (contentIndex === undefined) {
465
+ this.close();
466
+ this.output.content.push({
467
+ type: "toolCall",
468
+ // The id is what the tool result is matched back to, so a
469
+ // call that arrives without one still needs a stable
470
+ // stand-in rather than an empty string shared by every call.
471
+ id: call.id ?? `call_${call.index}`,
472
+ name: call.function?.name ?? "",
473
+ arguments: {},
474
+ } as never);
475
+ contentIndex = this.output.content.length - 1;
476
+ this.toolIndexToContent.set(call.index, contentIndex);
477
+ this.rawArguments.set(contentIndex, "");
478
+ this.open = { kind: "toolCall", index: contentIndex };
479
+ this.stream.push({ type: "toolcall_start", contentIndex, partial: this.output });
480
+ } else if (this.open?.index !== contentIndex) {
481
+ // Fragments for a call resumed after another block opened.
482
+ this.close();
483
+ this.open = { kind: "toolCall", index: contentIndex };
484
+ }
485
+
486
+ const block = this.output.content[contentIndex] as { id: string; name: string };
487
+ if (call.id) {
488
+ block.id = call.id;
489
+ }
490
+ if (call.function?.name) {
491
+ block.name = call.function.name;
492
+ }
493
+
494
+ const fragment = call.function?.arguments ?? "";
495
+ if (fragment) {
496
+ this.rawArguments.set(contentIndex, (this.rawArguments.get(contentIndex) ?? "") + fragment);
497
+ this.stream.push({ type: "toolcall_delta", contentIndex, delta: fragment, partial: this.output });
498
+ }
499
+ }
500
+
501
+ // close ends the open block, emitting the matching end event.
502
+ close(): void {
503
+ if (!this.open) {
504
+ return;
505
+ }
506
+ const { kind, index } = this.open;
507
+ this.open = undefined;
508
+
509
+ if (kind === "text") {
510
+ const content = (this.output.content[index] as { text: string }).text;
511
+ this.stream.push({ type: "text_end", contentIndex: index, content, partial: this.output });
512
+ return;
513
+ }
514
+
515
+ if (kind === "thinking") {
516
+ const content = (this.output.content[index] as { thinking: string }).thinking;
517
+ this.stream.push({ type: "thinking_end", contentIndex: index, content, partial: this.output });
518
+ return;
519
+ }
520
+
521
+ const block = this.output.content[index] as { arguments: Record<string, unknown> };
522
+ block.arguments = parseToolArguments(this.rawArguments.get(index) ?? "");
523
+ this.stream.push({
524
+ type: "toolcall_end",
525
+ contentIndex: index,
526
+ toolCall: this.output.content[index] as never,
527
+ partial: this.output,
528
+ });
529
+ }
530
+
531
+ // ensure returns the index of the open block of the given kind, opening one
532
+ // when the current block is of another kind.
533
+ private ensure(kind: "text" | "thinking", make: () => object): number {
534
+ if (this.open?.kind === kind) {
535
+ return this.open.index;
536
+ }
537
+ this.close();
538
+ this.output.content.push(make() as never);
539
+ const index = this.output.content.length - 1;
540
+ this.open = { kind, index };
541
+ this.stream.push({
542
+ type: kind === "text" ? "text_start" : "thinking_start",
543
+ contentIndex: index,
544
+ partial: this.output,
545
+ });
546
+ return index;
547
+ }
548
+ }
549
+
550
+ // parseToolArguments turns the accumulated argument text into an object. A
551
+ // model can stream arguments that never parse, and an empty run is normal for
552
+ // a no-argument tool. Neither is worth failing the whole turn over: an empty
553
+ // object lets the tool reject the call on its own terms, which reads better
554
+ // than the stream erroring out.
555
+ export function parseToolArguments(raw: string): Record<string, unknown> {
556
+ const text = raw.trim();
557
+ if (!text) {
558
+ return {};
559
+ }
560
+ try {
561
+ const parsed = JSON.parse(text);
562
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
563
+ } catch {
564
+ console.warn("pi-402: tool call arguments did not parse as JSON, passing an empty object:", text);
565
+ return {};
566
+ }
567
+ }