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