@lucid-agents/pi-xgate 0.0.0-stage → 0.1.5

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,271 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ createAssistantMessageEventStream,
4
+ type AssistantMessage,
5
+ type Model,
6
+ type Provider,
7
+ type SimpleStreamOptions,
8
+ type TranscriptContext,
9
+ } from "@earendil-works/pi-ai";
10
+ import {
11
+ XgateError,
12
+ type PurchaseResult,
13
+ type Invocation,
14
+ } from "@lucid-agents/xgate-client/types";
15
+ import { catalogEntries, encode, decode } from "./codec.ts";
16
+ const hash = (value: string): string =>
17
+ createHash("sha256").update(value).digest("hex");
18
+ export interface Broker {
19
+ models(signal?: AbortSignal): Promise<any[]>;
20
+ purchase(
21
+ input: Invocation,
22
+ signal?: AbortSignal,
23
+ onResponse?: (response: {
24
+ status: number;
25
+ headers: Record<string, string>;
26
+ }) => Promise<void>,
27
+ ): Promise<PurchaseResult>;
28
+ }
29
+ const zero = () => ({
30
+ input: 0,
31
+ output: 0,
32
+ cacheRead: 0,
33
+ cacheWrite: 0,
34
+ totalTokens: 0,
35
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
36
+ });
37
+ /** Stable across automatic retries and payload hooks; a changed paid payload cannot claim a new intent. */
38
+ export function invocationId(
39
+ model: string,
40
+ context: TranscriptContext,
41
+ session: string,
42
+ ): string {
43
+ return hash(
44
+ JSON.stringify({
45
+ session,
46
+ model,
47
+ messages: context.messages.filter(
48
+ (m) =>
49
+ m.role !== "assistant" ||
50
+ !["error", "aborted"].includes(m.stopReason),
51
+ ),
52
+ }),
53
+ );
54
+ }
55
+ export async function createXgateProvider(
56
+ broker: Broker,
57
+ identity: () => string = () => "standalone",
58
+ onPaid?: (result: PurchaseResult) => void,
59
+ ): Promise<Provider> {
60
+ let entries = catalogEntries(await broker.models());
61
+ const stream = (
62
+ model: Model<any>,
63
+ context: TranscriptContext,
64
+ options: SimpleStreamOptions = {},
65
+ ) => {
66
+ const events = createAssistantMessageEventStream();
67
+ const timeout = options.timeoutMs ?? 180000;
68
+ if (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 180000)
69
+ options = { ...options, timeoutMs: 180000 };
70
+ const deadline = AbortSignal.timeout(
71
+ Math.min(options.timeoutMs ?? 180000, 180000),
72
+ );
73
+ options = {
74
+ ...options,
75
+ signal: options.signal
76
+ ? AbortSignal.any([options.signal, deadline])
77
+ : deadline,
78
+ };
79
+ const message: AssistantMessage = {
80
+ role: "assistant",
81
+ provider: "xgate",
82
+ api: model.api,
83
+ model: model.id,
84
+ content: [],
85
+ usage: zero(),
86
+ stopReason: "pending",
87
+ timestamp: Date.now(),
88
+ };
89
+ void (async () => {
90
+ try {
91
+ options.signal?.throwIfAborted();
92
+ const current = catalogEntries(await broker.models(options.signal)),
93
+ entry = current.find(
94
+ (e) => e.model.id === model.id && e.model.api === model.api,
95
+ );
96
+ if (!entry) throw new XgateError("model_not_qualified");
97
+ const original = encode(entry, context, {
98
+ ...options,
99
+ // Respect a lower Pi model override without exceeding live catalog limits.
100
+ maxTokens:
101
+ options.maxTokens ??
102
+ Math.min(4096, model.maxTokens, entry.model.maxTokens),
103
+ }),
104
+ replacement = await options.onPayload?.(
105
+ structuredClone(original),
106
+ model,
107
+ ),
108
+ payload = replacement === undefined ? original : (replacement as any);
109
+ const limit = entry.route.endsWith("/messages")
110
+ ? "max_tokens"
111
+ : "max_output_tokens";
112
+ if (
113
+ !payload ||
114
+ payload.model !== model.id ||
115
+ payload.stream !== false ||
116
+ payload[limit] !== original[limit] ||
117
+ JSON.stringify(payload.tools) !== JSON.stringify(original.tools) ||
118
+ (!entry.route.endsWith("/messages") && payload.store !== false)
119
+ )
120
+ throw new XgateError("payload_hook_changed_scope");
121
+ const body = JSON.stringify(payload);
122
+ if (Buffer.byteLength(body) > 8 * 1024 * 1024)
123
+ throw new XgateError("request_bound");
124
+ const id = invocationId(
125
+ model.id,
126
+ context,
127
+ options.sessionId ?? identity(),
128
+ );
129
+ let observed = false;
130
+ const result = await broker.purchase(
131
+ { id, route: entry.route, body },
132
+ options.signal,
133
+ async (response) => {
134
+ observed = true;
135
+ await options.onResponse?.(response, model);
136
+ },
137
+ );
138
+ // In-process injected brokers have no HTTP body; retain the observation hook there too.
139
+ if (!observed)
140
+ await options.onResponse?.(
141
+ {
142
+ status: 200,
143
+ headers: {
144
+ "content-type": "application/json",
145
+ "cache-control": "no-store",
146
+ },
147
+ },
148
+ model,
149
+ );
150
+ const value = decode(entry, result.body);
151
+ message.usage = value.usage;
152
+ message.responseId = value.responseId;
153
+ options.signal?.throwIfAborted();
154
+ events.push({ type: "start", partial: message });
155
+ for (const block of value.content) {
156
+ const contentIndex = message.content.length;
157
+ if (block.type === "text") {
158
+ message.content.push({ ...block, text: "" });
159
+ events.push({ type: "text_start", contentIndex, partial: message });
160
+ (message.content[contentIndex] as typeof block).text = block.text;
161
+ events.push({
162
+ type: "text_delta",
163
+ contentIndex,
164
+ delta: block.text,
165
+ partial: message,
166
+ });
167
+ events.push({
168
+ type: "text_end",
169
+ contentIndex,
170
+ content: block.text,
171
+ partial: message,
172
+ });
173
+ } else if (block.type === "thinking") {
174
+ message.content.push({
175
+ ...block,
176
+ thinking: block.redacted ? block.thinking : "",
177
+ });
178
+ events.push({
179
+ type: "thinking_start",
180
+ contentIndex,
181
+ partial: message,
182
+ });
183
+ if (!block.redacted) {
184
+ (message.content[contentIndex] as typeof block).thinking =
185
+ block.thinking;
186
+ events.push({
187
+ type: "thinking_delta",
188
+ contentIndex,
189
+ delta: block.thinking,
190
+ partial: message,
191
+ });
192
+ }
193
+ events.push({
194
+ type: "thinking_end",
195
+ contentIndex,
196
+ content: block.thinking,
197
+ partial: message,
198
+ });
199
+ } else {
200
+ message.content.push({ ...block, arguments: {} });
201
+ events.push({
202
+ type: "toolcall_start",
203
+ contentIndex,
204
+ partial: message,
205
+ });
206
+ (message.content[contentIndex] as typeof block).arguments =
207
+ block.arguments;
208
+ events.push({
209
+ type: "toolcall_delta",
210
+ contentIndex,
211
+ delta: JSON.stringify(block.arguments),
212
+ partial: message,
213
+ });
214
+ events.push({
215
+ type: "toolcall_end",
216
+ contentIndex,
217
+ toolCall: block,
218
+ partial: message,
219
+ });
220
+ }
221
+ }
222
+ message.stopReason = value.stopReason;
223
+ try {
224
+ onPaid?.(result);
225
+ } catch {
226
+ /* UI diagnostics cannot reverse verified completion. */
227
+ }
228
+ events.push({ type: "done", reason: value.stopReason, message });
229
+ } catch (error) {
230
+ const aborted = options.signal?.aborted === true;
231
+ message.stopReason = aborted ? "aborted" : "error";
232
+ message.errorMessage = aborted
233
+ ? "Xgate operation stopped. Inspect /xgate status; payment may require reconciliation."
234
+ : error instanceof XgateError
235
+ ? error.message
236
+ : "Xgate operation stopped. Inspect /xgate status before requesting another purchase.";
237
+ events.push({
238
+ type: "error",
239
+ reason: message.stopReason,
240
+ error: message,
241
+ });
242
+ } finally {
243
+ events.end();
244
+ }
245
+ })();
246
+ return events;
247
+ };
248
+ return {
249
+ id: "xgate",
250
+ name: "xgate",
251
+ auth: {
252
+ apiKey: {
253
+ name: "Policy constrained xgate wallet",
254
+ resolve: async () => ({ auth: {}, source: "xgate wallet broker" }),
255
+ },
256
+ },
257
+ getModels: () => entries.map((e) => e.model),
258
+ refreshModels: async (context) => {
259
+ if (!context.allowNetwork) return;
260
+ const updated = catalogEntries(await broker.models(context.signal));
261
+ await context.publish({
262
+ update: () => {
263
+ entries = updated;
264
+ },
265
+ });
266
+ },
267
+ stream: (model, context, options) =>
268
+ stream(model, context, options as SimpleStreamOptions),
269
+ streamSimple: stream,
270
+ };
271
+ }