@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.
- package/INSTALL.md +270 -0
- package/LICENSE +21 -0
- package/README.md +190 -2
- package/package.json +47 -5
- package/src/CLAUDE.md +57 -0
- package/src/commands.ts +104 -0
- package/src/index.ts +114 -0
- package/src/payer.ts +34 -0
- package/src/policy.ts +209 -0
- package/src/provider.ts +567 -0
- package/src/store.ts +387 -0
- package/src/wallet.ts +1109 -0
- package/wasm/package.json +1 -0
- package/wasm/sqlite-bridge.js +240 -0
- package/wasm/sqlite-node-vfs.js +894 -0
- package/wasm/sqlite-worker.js +850 -0
- package/wasm/sqlite3.js +14318 -0
- package/wasm/sqlite3.wasm +0 -0
- package/wasm/wasm_exec.js +575 -0
- package/wasm/wavewalletdk.wasm +4 -0
package/src/provider.ts
ADDED
|
@@ -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
|
+
}
|