@lucid-agents/pi-xgate 0.1.5 → 0.1.7
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/README.md +103 -7
- package/dist/codec.d.ts +40 -0
- package/dist/codec.js +553 -0
- package/dist/extension.d.ts +16 -0
- package/dist/extension.js +132 -0
- package/dist/generated/openai-model-capabilities.json +847 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +7 -0
- package/dist/provider.d.ts +24 -0
- package/dist/provider.js +243 -0
- package/dist/taskmarket.d.ts +131 -0
- package/dist/taskmarket.js +275 -0
- package/dist/transport.d.ts +72 -0
- package/dist/transport.js +21 -0
- package/package.json +21 -9
- package/src/codec.ts +117 -26
- package/src/extension.ts +60 -12
- package/src/index.ts +45 -0
- package/src/provider.ts +68 -26
- package/src/taskmarket.ts +484 -0
- package/src/transport.ts +94 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// Adapts TaskMarket's x402 payer to the xgate Pi transport. The payer package is private, so
|
|
2
|
+
// this file declares the structural subset of its v1 contract (`@taskmarket/x402-payer`
|
|
3
|
+
// `src/api.ts`, X402_PAYER_API_VERSION 1) that the adapter uses; TaskMarket injects its
|
|
4
|
+
// instance. The payer owns the wallet, signer, rails, policy and journals: this adapter keeps
|
|
5
|
+
// no payment state of its own.
|
|
6
|
+
//
|
|
7
|
+
// The payer's string unions may gain values without a version bump. Every type here accepts
|
|
8
|
+
// unknown strings, and the adapter treats an unknown state as unverified.
|
|
9
|
+
import { atomic, origin as checkOrigin, record, route as checkRoute, XgateError, } from "@lucid-agents/xgate-client/types";
|
|
10
|
+
import { XgatePurchaseError, } from "./transport.js";
|
|
11
|
+
const RESOLVED_CHARGES = ["none", "released", "settled", "manually_resolved"], RESOLVED_STATES = ["completed", "failed"];
|
|
12
|
+
const CATALOG_BYTES = 2 * 1024 * 1024, REQUEST_BYTES = 8 * 1024 * 1024, CATALOG_DEADLINE_MS = 15000;
|
|
13
|
+
/** Never sends the request again for an ID the payer has seen: `purchase` maps every non-verified outcome to a terminal error. */
|
|
14
|
+
export function createTaskMarketTransport(options) {
|
|
15
|
+
const { payer } = options, base = checkOrigin(options.origin), fetcher = options.fetch ?? fetch;
|
|
16
|
+
if (payer.apiVersion !== 1)
|
|
17
|
+
throw new XgateError("unsupported_payer_api");
|
|
18
|
+
const channel = async (payment) => {
|
|
19
|
+
if (!options.balances || payment.rail.kind !== "session")
|
|
20
|
+
return;
|
|
21
|
+
try {
|
|
22
|
+
const matches = (await options.balances()).filter((b) => b.railId === payment.rail.id &&
|
|
23
|
+
b.network === payment.network &&
|
|
24
|
+
b.asset === payment.asset);
|
|
25
|
+
// Several sessions on one rail are not one channel; unlike balances are never summed.
|
|
26
|
+
if (matches.length !== 1)
|
|
27
|
+
return;
|
|
28
|
+
const b = matches[0];
|
|
29
|
+
return {
|
|
30
|
+
depositingAtomic: b.depositing,
|
|
31
|
+
lockedAtomic: b.locked,
|
|
32
|
+
chargedAtomic: b.charged,
|
|
33
|
+
pendingAtomic: b.pending,
|
|
34
|
+
refundableAtomic: b.refundable,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return; // Display only.
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
async models(signal) {
|
|
43
|
+
const deadline = AbortSignal.timeout(CATALOG_DEADLINE_MS);
|
|
44
|
+
let response;
|
|
45
|
+
try {
|
|
46
|
+
response = await fetcher(base + "/v1/models", {
|
|
47
|
+
signal: signal ? AbortSignal.any([signal, deadline]) : deadline,
|
|
48
|
+
redirect: "error",
|
|
49
|
+
cache: "no-store",
|
|
50
|
+
credentials: "omit",
|
|
51
|
+
referrerPolicy: "no-referrer",
|
|
52
|
+
headers: { accept: "application/json" },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw new XgateError("catalog_unavailable");
|
|
57
|
+
}
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
await response.body?.cancel().catch(() => { });
|
|
60
|
+
throw new XgateError("catalog_unavailable");
|
|
61
|
+
}
|
|
62
|
+
const v = parse(await bounded(response, CATALOG_BYTES));
|
|
63
|
+
if (!record(v) || !Array.isArray(v.data) || v.data.length > 128)
|
|
64
|
+
throw new XgateError("invalid_catalog");
|
|
65
|
+
return v.data
|
|
66
|
+
.filter((m) => record(m) &&
|
|
67
|
+
(options.models === undefined || options.models.includes(m.id)))
|
|
68
|
+
.map((m) => ({ ...m, xgate_origin: base }));
|
|
69
|
+
},
|
|
70
|
+
async purchase(invocation, signal, onResponse) {
|
|
71
|
+
checkRoute(invocation.route);
|
|
72
|
+
const body = new TextEncoder().encode(invocation.body);
|
|
73
|
+
if (body.byteLength === 0 || body.byteLength > REQUEST_BYTES)
|
|
74
|
+
throw new XgateError("invalid_invocation");
|
|
75
|
+
const id = invocation.id;
|
|
76
|
+
let result;
|
|
77
|
+
try {
|
|
78
|
+
result = await payer.request({
|
|
79
|
+
apiVersion: 1,
|
|
80
|
+
invocationId: id,
|
|
81
|
+
url: base + invocation.route,
|
|
82
|
+
method: "POST",
|
|
83
|
+
// Application headers only: the payer owns every payment header.
|
|
84
|
+
headers: {
|
|
85
|
+
"content-type": "application/json",
|
|
86
|
+
accept: "application/json",
|
|
87
|
+
},
|
|
88
|
+
body,
|
|
89
|
+
limits: { maxRequestBodyBytes: REQUEST_BYTES },
|
|
90
|
+
...(options.allocationId !== undefined
|
|
91
|
+
? { allocationId: options.allocationId }
|
|
92
|
+
: {}),
|
|
93
|
+
...(options.policyRuleId !== undefined
|
|
94
|
+
? { policyRuleId: options.policyRuleId }
|
|
95
|
+
: {}),
|
|
96
|
+
...(options.nonInteractive !== undefined
|
|
97
|
+
? { nonInteractive: options.nonInteractive }
|
|
98
|
+
: {}),
|
|
99
|
+
}, {
|
|
100
|
+
...(signal ? { signal } : {}),
|
|
101
|
+
...(options.confirmPayment
|
|
102
|
+
? { confirmPayment: options.confirmPayment }
|
|
103
|
+
: {}),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
throw failure(error, id);
|
|
108
|
+
}
|
|
109
|
+
const payment = result.payment;
|
|
110
|
+
if (result.invocationId !== id || result.apiVersion !== 1)
|
|
111
|
+
throw new XgatePurchaseError("payer_result_mismatch", id, true, payment && display(payment));
|
|
112
|
+
// A paid xgate route never answers without a payment challenge.
|
|
113
|
+
if (!payment)
|
|
114
|
+
throw new XgatePurchaseError(result.response.status === 200
|
|
115
|
+
? "payment_missing"
|
|
116
|
+
: "request_rejected", id, false);
|
|
117
|
+
const shown = display(payment);
|
|
118
|
+
if (payment.charge !== "settled" ||
|
|
119
|
+
typeof payment.settledAmount !== "string")
|
|
120
|
+
throw new XgatePurchaseError("payment_unverified", id, true, shown);
|
|
121
|
+
shown.chargedAtomic = payment.settledAmount;
|
|
122
|
+
try {
|
|
123
|
+
if (atomic(payment.settledAmount) > atomic(payment.authorizedAmount))
|
|
124
|
+
throw new XgateError("charge_over_authorization");
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
throw new XgatePurchaseError("invalid_settlement", id, true, shown);
|
|
128
|
+
}
|
|
129
|
+
const r = result.response, h = r.headers;
|
|
130
|
+
// Charged, so not pending, but the content is unusable.
|
|
131
|
+
if (r.status !== 200 ||
|
|
132
|
+
!/^application\/json\b/i.test(r.contentType) ||
|
|
133
|
+
h["x-inference-privacy"] !== "anonymous" ||
|
|
134
|
+
h["x-result-retention"] !== "none" ||
|
|
135
|
+
!h["cache-control"]?.includes("no-store"))
|
|
136
|
+
throw new XgatePurchaseError("response_rejected", id, false, shown);
|
|
137
|
+
let value;
|
|
138
|
+
try {
|
|
139
|
+
value = parse(r.body);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
throw new XgatePurchaseError("invalid_response", id, false, shown);
|
|
143
|
+
}
|
|
144
|
+
if (!record(value) ||
|
|
145
|
+
typeof value.id !== "string" ||
|
|
146
|
+
value.result_replay_available !== undefined)
|
|
147
|
+
throw new XgatePurchaseError("invalid_response", id, false, shown);
|
|
148
|
+
const head = {
|
|
149
|
+
status: r.status,
|
|
150
|
+
headers: { "content-type": r.contentType, "cache-control": "no-store" },
|
|
151
|
+
};
|
|
152
|
+
try {
|
|
153
|
+
await onResponse?.(head);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
throw new XgatePurchaseError("response_hook_failed", id, false, shown);
|
|
157
|
+
}
|
|
158
|
+
const balance = await channel(payment);
|
|
159
|
+
if (balance)
|
|
160
|
+
shown.channel = balance;
|
|
161
|
+
return {
|
|
162
|
+
body: value,
|
|
163
|
+
settlement: {
|
|
164
|
+
chargedAtomic: payment.settledAmount,
|
|
165
|
+
reference: payment.transaction ?? payment.commitment ?? payment.paymentId,
|
|
166
|
+
},
|
|
167
|
+
purchase: payment.commitment ?? payment.paymentId,
|
|
168
|
+
payment: shown,
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
async status() {
|
|
172
|
+
return {
|
|
173
|
+
transport: "taskmarket",
|
|
174
|
+
origin: base,
|
|
175
|
+
...(options.balances
|
|
176
|
+
? {
|
|
177
|
+
balances: (await options.balances()).map((b) => ({
|
|
178
|
+
rail: b.railId,
|
|
179
|
+
network: b.network,
|
|
180
|
+
asset: b.asset,
|
|
181
|
+
depositingAtomic: b.depositing,
|
|
182
|
+
lockedAtomic: b.locked,
|
|
183
|
+
chargedAtomic: b.charged,
|
|
184
|
+
pendingAtomic: b.pending,
|
|
185
|
+
refundableAtomic: b.refundable,
|
|
186
|
+
})),
|
|
187
|
+
}
|
|
188
|
+
: {}),
|
|
189
|
+
};
|
|
190
|
+
},
|
|
191
|
+
async reconcile(invocationId) {
|
|
192
|
+
const s = await payer.reconcile(invocationId);
|
|
193
|
+
return {
|
|
194
|
+
invocationId: s.invocationId,
|
|
195
|
+
state: s.state,
|
|
196
|
+
charge: s.charge,
|
|
197
|
+
uncertain: s.uncertain,
|
|
198
|
+
...(s.errorCode ? { errorCode: s.errorCode } : {}),
|
|
199
|
+
...(s.payment ? { payment: display(s.payment) } : {}),
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function display(p) {
|
|
205
|
+
return {
|
|
206
|
+
network: p.network,
|
|
207
|
+
scheme: p.scheme,
|
|
208
|
+
asset: p.asset,
|
|
209
|
+
payTo: p.payTo,
|
|
210
|
+
rail: p.rail.kind,
|
|
211
|
+
authorizedAtomic: p.authorizedAmount,
|
|
212
|
+
...(p.charge === "settled" && p.settledAmount !== undefined
|
|
213
|
+
? { chargedAtomic: p.settledAmount }
|
|
214
|
+
: {}),
|
|
215
|
+
status: p.charge,
|
|
216
|
+
...((p.transaction ?? p.commitment)
|
|
217
|
+
? { reference: p.transaction ?? p.commitment }
|
|
218
|
+
: {}),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Maps a payer rejection. Only the stable code crosses into Pi; the payer's message may carry
|
|
223
|
+
* URLs or server text. A rejection that does not state `pending: false` counts as pending.
|
|
224
|
+
*/
|
|
225
|
+
function failure(error, id) {
|
|
226
|
+
const e = error;
|
|
227
|
+
const code = typeof e?.code === "string" && /^[a-z0-9_]{1,64}$/.test(e.code)
|
|
228
|
+
? e.code
|
|
229
|
+
: "payer_failed";
|
|
230
|
+
// A duplicate never sends again, but its recorded invocation may still be unresolved: only a
|
|
231
|
+
// positively resolved record counts as not pending.
|
|
232
|
+
const recorded = e?.invocation;
|
|
233
|
+
const pending = e?.pending !== false ||
|
|
234
|
+
(recorded !== undefined &&
|
|
235
|
+
!(recorded.uncertain === false &&
|
|
236
|
+
RESOLVED_CHARGES.includes(recorded.charge) &&
|
|
237
|
+
RESOLVED_STATES.includes(recorded.state)));
|
|
238
|
+
const summary = e?.invocation?.payment;
|
|
239
|
+
return new XgatePurchaseError(code, id, pending, record(summary) ? display(summary) : undefined);
|
|
240
|
+
}
|
|
241
|
+
async function bounded(response, limit) {
|
|
242
|
+
if (!response.body)
|
|
243
|
+
throw new XgateError("invalid_catalog");
|
|
244
|
+
const reader = response.body.getReader(), chunks = [];
|
|
245
|
+
let length = 0;
|
|
246
|
+
try {
|
|
247
|
+
for (;;) {
|
|
248
|
+
const part = await reader.read();
|
|
249
|
+
if (part.done)
|
|
250
|
+
break;
|
|
251
|
+
length += part.value.byteLength;
|
|
252
|
+
if (length > limit)
|
|
253
|
+
throw new XgateError("catalog_bound");
|
|
254
|
+
chunks.push(part.value);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
await reader.cancel().catch(() => { });
|
|
259
|
+
throw error instanceof XgateError
|
|
260
|
+
? error
|
|
261
|
+
: new XgateError("catalog_unavailable");
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
reader.releaseLock();
|
|
265
|
+
}
|
|
266
|
+
return Buffer.concat(chunks, length);
|
|
267
|
+
}
|
|
268
|
+
function parse(bytes) {
|
|
269
|
+
try {
|
|
270
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
throw new XgateError("invalid_response");
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { XgateError, type Invocation, type PurchaseResult } from "@lucid-agents/xgate-client/types";
|
|
2
|
+
/** Response metadata safe to show Pi's `onResponse` hook: never payment or status credentials. */
|
|
3
|
+
export interface XgateResponseHead {
|
|
4
|
+
status: number;
|
|
5
|
+
headers: Record<string, string>;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Rail-neutral payment terms and status for display. Pi shows them; it never selects or
|
|
9
|
+
* configures a rail. Amounts are unsigned integer strings in the asset's smallest unit, and
|
|
10
|
+
* amounts of different assets are never combined.
|
|
11
|
+
*/
|
|
12
|
+
export interface XgatePayment {
|
|
13
|
+
/** CAIP-2 network, e.g. `eip155:8453`. */
|
|
14
|
+
network: string;
|
|
15
|
+
/** Payment scheme as the payee advertised it, e.g. `batch-settlement`, `exact`, `session`. */
|
|
16
|
+
scheme: string;
|
|
17
|
+
asset: string;
|
|
18
|
+
payTo?: string;
|
|
19
|
+
/** Rail shape: `per-request` or `session` today; other values may appear. */
|
|
20
|
+
rail?: string;
|
|
21
|
+
/** Most this invocation could be charged. */
|
|
22
|
+
authorizedAtomic: string;
|
|
23
|
+
/** Verified charge, once known. */
|
|
24
|
+
chargedAtomic?: string;
|
|
25
|
+
/** `settled`, `pending`, `amount_unknown`, `released`, ...; anything but `settled` is unverified. */
|
|
26
|
+
status: string;
|
|
27
|
+
/** Settlement transaction or payee commitment, when one exists. */
|
|
28
|
+
reference?: string;
|
|
29
|
+
/** Session-rail balance after this payment, when the host reports one. */
|
|
30
|
+
channel?: XgateChannelBalance;
|
|
31
|
+
}
|
|
32
|
+
export interface XgateChannelBalance {
|
|
33
|
+
depositingAtomic: string;
|
|
34
|
+
lockedAtomic: string;
|
|
35
|
+
chargedAtomic: string;
|
|
36
|
+
pendingAtomic: string;
|
|
37
|
+
refundableAtomic: string;
|
|
38
|
+
}
|
|
39
|
+
export interface XgatePurchaseResult extends PurchaseResult {
|
|
40
|
+
payment?: XgatePayment;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A purchase that ended without a verified result. `pending` is true when money may have moved
|
|
44
|
+
* and the outcome is unknown: the invocation must be reconciled, never retried.
|
|
45
|
+
*/
|
|
46
|
+
export declare class XgatePurchaseError extends XgateError {
|
|
47
|
+
readonly invocationId: string;
|
|
48
|
+
readonly pending: boolean;
|
|
49
|
+
readonly payment?: XgatePayment;
|
|
50
|
+
constructor(code: string, invocationId: string, pending: boolean, payment?: XgatePayment);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Moves one frozen Pi request to xgate and returns the verified result. The provider owns
|
|
54
|
+
* catalog qualification, codecs and the invocation ID; the transport owns payment.
|
|
55
|
+
*
|
|
56
|
+
* `purchase` sends at most one paid submission per `Invocation.id`, never retries a paid
|
|
57
|
+
* request, and never pays again for an ID it has seen: a repeated ID rejects. It resolves
|
|
58
|
+
* only with a verified result and rejects on every other outcome, preferably with an
|
|
59
|
+
* `XgatePurchaseError` stating whether the payment is pending. `BrokerClient` is the
|
|
60
|
+
* standalone implementation.
|
|
61
|
+
*/
|
|
62
|
+
export interface XgateTransport {
|
|
63
|
+
/** Current router catalog rows. Called before every paid invocation. */
|
|
64
|
+
models(signal?: AbortSignal): Promise<unknown[]>;
|
|
65
|
+
purchase(invocation: Invocation, signal?: AbortSignal, onResponse?: (response: XgateResponseHead) => Promise<void>): Promise<XgatePurchaseResult>;
|
|
66
|
+
/** Optional `/xgate status` and `/xgate budget` view. */
|
|
67
|
+
status?(signal?: AbortSignal): Promise<unknown>;
|
|
68
|
+
/** Optional `/xgate receipts` view. */
|
|
69
|
+
receipts?(signal?: AbortSignal): Promise<unknown>;
|
|
70
|
+
/** Optional `/xgate reconcile <invocation ID>`. Never resends the request. */
|
|
71
|
+
reconcile?(invocationId: string, signal?: AbortSignal): Promise<unknown>;
|
|
72
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { XgateError, } from "@lucid-agents/xgate-client/types";
|
|
2
|
+
/**
|
|
3
|
+
* A purchase that ended without a verified result. `pending` is true when money may have moved
|
|
4
|
+
* and the outcome is unknown: the invocation must be reconciled, never retried.
|
|
5
|
+
*/
|
|
6
|
+
export class XgatePurchaseError extends XgateError {
|
|
7
|
+
invocationId;
|
|
8
|
+
pending;
|
|
9
|
+
payment;
|
|
10
|
+
constructor(code, invocationId, pending, payment) {
|
|
11
|
+
super(code);
|
|
12
|
+
this.name = "XgatePurchaseError";
|
|
13
|
+
this.message = pending
|
|
14
|
+
? `Xgate: ${code}. The payment outcome is unknown; do not retry. Reconcile it before another purchase.`
|
|
15
|
+
: `Xgate: ${code}. Inspect the payment status before another purchase.`;
|
|
16
|
+
this.invocationId = invocationId;
|
|
17
|
+
this.pending = pending;
|
|
18
|
+
if (payment)
|
|
19
|
+
this.payment = payment;
|
|
20
|
+
}
|
|
21
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lucid-agents/pi-xgate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"default": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"./extension": {
|
|
11
|
+
"types": "./dist/extension.d.ts",
|
|
12
|
+
"default": "./dist/extension.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
5
16
|
"keywords": [
|
|
6
17
|
"pi-package",
|
|
7
18
|
"xgate",
|
|
@@ -15,19 +26,20 @@
|
|
|
15
26
|
]
|
|
16
27
|
},
|
|
17
28
|
"files": [
|
|
29
|
+
"dist",
|
|
18
30
|
"src",
|
|
19
31
|
"README.md",
|
|
20
32
|
"LICENSE"
|
|
21
33
|
],
|
|
22
34
|
"dependencies": {
|
|
23
|
-
"@lucid-agents/xgate-client": "0.1.
|
|
35
|
+
"@lucid-agents/xgate-client": "0.1.7"
|
|
24
36
|
},
|
|
25
37
|
"peerDependencies": {
|
|
26
|
-
"@earendil-works/pi-ai": "1.1.0",
|
|
27
|
-
"@earendil-works/pi-coding-agent": "1.1.0"
|
|
38
|
+
"@earendil-works/pi-ai": "0.85.1 || 1.1.0",
|
|
39
|
+
"@earendil-works/pi-coding-agent": "0.85.1 || 1.1.0"
|
|
28
40
|
},
|
|
29
41
|
"engines": {
|
|
30
|
-
"node": ">=24.19
|
|
42
|
+
"node": ">=24.19"
|
|
31
43
|
},
|
|
32
44
|
"description": "Wallet-paid OpenAI and Anthropic inference through xgate for the Pi coding agent",
|
|
33
45
|
"repository": {
|
|
@@ -44,10 +56,10 @@
|
|
|
44
56
|
"registry": "https://registry.npmjs.org"
|
|
45
57
|
},
|
|
46
58
|
"license": "MIT",
|
|
47
|
-
"gitHead": "
|
|
59
|
+
"gitHead": "90a64abcca7028250d4092c42b37a9d627bde8fe",
|
|
48
60
|
"xgateRelease": {
|
|
49
|
-
"version": "v0.1.
|
|
50
|
-
"sourceSha": "
|
|
51
|
-
"candidateId": "rc-
|
|
61
|
+
"version": "v0.1.7",
|
|
62
|
+
"sourceSha": "90a64abcca7028250d4092c42b37a9d627bde8fe",
|
|
63
|
+
"candidateId": "rc-90a64abcca70-38543861426-1"
|
|
52
64
|
}
|
|
53
65
|
}
|
package/src/codec.ts
CHANGED
|
@@ -1,22 +1,39 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
import * as pi from "@earendil-works/pi-ai";
|
|
2
|
+
import type {
|
|
3
|
+
AssistantMessage,
|
|
4
|
+
Context,
|
|
5
|
+
Message,
|
|
6
|
+
Model,
|
|
7
|
+
SimpleStreamOptions,
|
|
8
|
+
Tool,
|
|
9
|
+
TranscriptContext,
|
|
10
|
+
Usage,
|
|
11
|
+
ThinkingLevelMap,
|
|
11
12
|
} from "@earendil-works/pi-ai";
|
|
12
13
|
import { XgateError, type Route } from "@lucid-agents/xgate-client/types";
|
|
13
14
|
import capabilities from "./generated/openai-model-capabilities.json" with { type: "json" };
|
|
14
15
|
import { createHash } from "node:crypto";
|
|
16
|
+
/** The widest Anthropic prompt-cache write lifetime a scope's qualification permits. */
|
|
17
|
+
export type CacheTtl = "none" | "5m" | "1h";
|
|
15
18
|
export type Entry = {
|
|
16
19
|
model: Model<any>;
|
|
17
20
|
catalog: any;
|
|
18
21
|
route: Route;
|
|
19
22
|
efforts: string[];
|
|
23
|
+
/** Anthropic only: requests may carry `cache_control` breakpoints up to this lifetime. */
|
|
24
|
+
cache: CacheTtl;
|
|
25
|
+
/** Anthropic only: the per-million-token price of a one-hour cache write. */
|
|
26
|
+
cacheWrite1h: number;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* The router quotes the class a request's `cache_control` markers select, and serves it only
|
|
30
|
+
* when the scope's qualification covers that class. A wider qualification covers narrower
|
|
31
|
+
* requests, so a cache-qualified scope still serves a request without markers.
|
|
32
|
+
*/
|
|
33
|
+
const ANTHROPIC_CACHE: Record<string, CacheTtl> = {
|
|
34
|
+
anthropic_no_cache: "none",
|
|
35
|
+
anthropic_ephemeral_5m: "5m",
|
|
36
|
+
anthropic_ephemeral_1h: "1h",
|
|
20
37
|
};
|
|
21
38
|
const need = (x: unknown, code: string): void => {
|
|
22
39
|
if (!x) throw new XgateError(code);
|
|
@@ -69,7 +86,7 @@ export function catalogEntries(rows: any[]): Entry[] {
|
|
|
69
86
|
!q ||
|
|
70
87
|
row.privacy?.revision !== "anonymous-volatile-v1" ||
|
|
71
88
|
row.privacy.result_replay_available !== false ||
|
|
72
|
-
(anthropic && q.cache_mode
|
|
89
|
+
(anthropic && !Object.hasOwn(ANTHROPIC_CACHE, q.cache_mode))
|
|
73
90
|
)
|
|
74
91
|
continue;
|
|
75
92
|
if (
|
|
@@ -122,8 +139,13 @@ export function catalogEntries(rows: any[]): Entry[] {
|
|
|
122
139
|
["minimal", "low", "medium", "high"].includes(level));
|
|
123
140
|
thinkingLevelMap[level] = allowed ? level : null;
|
|
124
141
|
}
|
|
142
|
+
const write5m = p.rates.input_cache_write_5m ?? p.rates.input_cache_write;
|
|
125
143
|
entries.push({
|
|
126
144
|
catalog: row,
|
|
145
|
+
cache: anthropic ? ANTHROPIC_CACHE[q.cache_mode]! : "none",
|
|
146
|
+
cacheWrite1h: p.rates.input_cache_write_1h
|
|
147
|
+
? rate(p.rates.input_cache_write_1h)
|
|
148
|
+
: 0,
|
|
127
149
|
route: anthropic
|
|
128
150
|
? "/providers/anthropic/v1/messages"
|
|
129
151
|
: "/providers/openai/v1/responses",
|
|
@@ -145,9 +167,7 @@ export function catalogEntries(rows: any[]): Entry[] {
|
|
|
145
167
|
input: rate(p.rates.input_uncached),
|
|
146
168
|
output: rate(p.rates.output),
|
|
147
169
|
cacheRead: rate(p.rates.input_cache_read),
|
|
148
|
-
cacheWrite:
|
|
149
|
-
? rate(p.rates.input_cache_write)
|
|
150
|
-
: 0,
|
|
170
|
+
cacheWrite: write5m ? rate(write5m) : 0,
|
|
151
171
|
},
|
|
152
172
|
inputLimits: { maxRequestBytes: 8 * 1024 * 1024 },
|
|
153
173
|
},
|
|
@@ -155,6 +175,45 @@ export function catalogEntries(rows: any[]): Entry[] {
|
|
|
155
175
|
}
|
|
156
176
|
return entries;
|
|
157
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Pi 1.1 passes a normalized transcript; Pi 0.85 passes `systemPrompt` and `tools` beside the
|
|
180
|
+
* messages. Structural, so published types do not name a type only one Pi version has.
|
|
181
|
+
*/
|
|
182
|
+
export type PiContext = {
|
|
183
|
+
messages: Message[];
|
|
184
|
+
systemPrompt?: string;
|
|
185
|
+
tools?: Tool[];
|
|
186
|
+
};
|
|
187
|
+
type Turn = Exclude<Message, { role: "system" }>;
|
|
188
|
+
export function transcript(context: PiContext): {
|
|
189
|
+
system: string;
|
|
190
|
+
tools: Tool[];
|
|
191
|
+
messages: Turn[];
|
|
192
|
+
} {
|
|
193
|
+
const legacy = "systemPrompt" in context || "tools" in context;
|
|
194
|
+
if (typeof pi.collapseSystemMessages !== "function") {
|
|
195
|
+
need(
|
|
196
|
+
!context.messages.some((m) => m.role === "system"),
|
|
197
|
+
"unsupported_context",
|
|
198
|
+
);
|
|
199
|
+
const raw = context as Context;
|
|
200
|
+
return {
|
|
201
|
+
system: raw.systemPrompt ?? "",
|
|
202
|
+
tools: raw.tools ?? [],
|
|
203
|
+
messages: raw.messages as Turn[],
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
const collapsed = pi.collapseSystemMessages(
|
|
207
|
+
legacy
|
|
208
|
+
? pi.normalizeContext(context as Context)
|
|
209
|
+
: (context as TranscriptContext),
|
|
210
|
+
);
|
|
211
|
+
return {
|
|
212
|
+
system: pi.getCurrentSystemPrompt(collapsed.messages),
|
|
213
|
+
tools: pi.getCurrentTools(collapsed.messages),
|
|
214
|
+
messages: collapsed.messages.filter((m): m is Turn => m.role !== "system"),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
158
217
|
function text(content: any): string {
|
|
159
218
|
if (typeof content === "string") return content;
|
|
160
219
|
need(
|
|
@@ -187,7 +246,7 @@ function validateTools(tools: any[], anthropic: boolean): void {
|
|
|
187
246
|
}
|
|
188
247
|
export function encode(
|
|
189
248
|
entry: Entry,
|
|
190
|
-
context:
|
|
249
|
+
context: PiContext,
|
|
191
250
|
options: SimpleStreamOptions = {},
|
|
192
251
|
): any {
|
|
193
252
|
need(
|
|
@@ -205,9 +264,7 @@ export function encode(
|
|
|
205
264
|
"unsupported_sampling",
|
|
206
265
|
);
|
|
207
266
|
const anthropic = entry.route.endsWith("/messages"),
|
|
208
|
-
|
|
209
|
-
system = getCurrentSystemPrompt(collapsed.messages),
|
|
210
|
-
tools = getCurrentTools(collapsed.messages);
|
|
267
|
+
{ system, tools, messages } = transcript(context);
|
|
211
268
|
validateTools(tools, anthropic);
|
|
212
269
|
const max = options.maxTokens ?? Math.min(4096, entry.model.maxTokens);
|
|
213
270
|
need(
|
|
@@ -234,8 +291,7 @@ export function encode(
|
|
|
234
291
|
else history.push({ role, content });
|
|
235
292
|
};
|
|
236
293
|
if (!anthropic && system) history.push({ role: "system", content: system });
|
|
237
|
-
for (const message of
|
|
238
|
-
if (message.role === "system") continue;
|
|
294
|
+
for (const message of messages) {
|
|
239
295
|
if (message.role === "user") {
|
|
240
296
|
const value = text(message.content);
|
|
241
297
|
if (anthropic) append("user", [{ type: "text", text: value }]);
|
|
@@ -360,23 +416,39 @@ export function encode(
|
|
|
360
416
|
need(history.length > 0 && history.length <= 256, "history_limit");
|
|
361
417
|
let request: any;
|
|
362
418
|
if (anthropic) {
|
|
363
|
-
const cap = entry.catalog.execution_profile.capabilities
|
|
419
|
+
const cap = entry.catalog.execution_profile.capabilities,
|
|
420
|
+
cache = cacheControl(entry.cache, options.cacheRetention);
|
|
364
421
|
request = {
|
|
365
422
|
model: entry.model.id,
|
|
366
423
|
messages: history,
|
|
367
424
|
max_tokens: max,
|
|
368
425
|
stream: false,
|
|
369
|
-
...(system
|
|
426
|
+
...(system
|
|
427
|
+
? {
|
|
428
|
+
system: cache
|
|
429
|
+
? [{ type: "text", text: system, cache_control: cache }]
|
|
430
|
+
: system,
|
|
431
|
+
}
|
|
432
|
+
: {}),
|
|
370
433
|
...(tools.length
|
|
371
434
|
? {
|
|
372
|
-
tools: tools.map((t) => ({
|
|
435
|
+
tools: tools.map((t, i) => ({
|
|
373
436
|
name: t.name,
|
|
374
437
|
description: t.description,
|
|
375
438
|
input_schema: t.parameters,
|
|
439
|
+
...(cache && i === tools.length - 1
|
|
440
|
+
? { cache_control: cache }
|
|
441
|
+
: {}),
|
|
376
442
|
})),
|
|
377
443
|
}
|
|
378
444
|
: {}),
|
|
379
445
|
};
|
|
446
|
+
// Anthropic caches nothing without a breakpoint. Like Pi's direct Anthropic provider, mark
|
|
447
|
+
// the tools, the system prompt and the newest turn: the next request in the conversation
|
|
448
|
+
// reads the whole previous prefix. A final user turn ends in text or a tool result.
|
|
449
|
+
const last = history.at(-1);
|
|
450
|
+
if (cache && last.role === "user")
|
|
451
|
+
last.content.at(-1).cache_control = cache;
|
|
380
452
|
if (effort) request.output_config = { effort };
|
|
381
453
|
if (cap.thinking.adaptive) request.thinking = { type: "adaptive" };
|
|
382
454
|
else if (requested && cap.thinking.enabled) {
|
|
@@ -419,6 +491,15 @@ export function encode(
|
|
|
419
491
|
);
|
|
420
492
|
return request;
|
|
421
493
|
}
|
|
494
|
+
function cacheControl(
|
|
495
|
+
qualified: CacheTtl,
|
|
496
|
+
retention: SimpleStreamOptions["cacheRetention"],
|
|
497
|
+
): { type: "ephemeral"; ttl?: "1h" } | undefined {
|
|
498
|
+
if (qualified === "none" || retention === "none") return undefined;
|
|
499
|
+
return retention === "long" && qualified === "1h"
|
|
500
|
+
? { type: "ephemeral", ttl: "1h" }
|
|
501
|
+
: { type: "ephemeral" };
|
|
502
|
+
}
|
|
422
503
|
function tokens(value: any): number {
|
|
423
504
|
need(Number.isSafeInteger(value) && value >= 0, "invalid_usage");
|
|
424
505
|
return value;
|
|
@@ -591,9 +672,17 @@ export function decode(
|
|
|
591
672
|
),
|
|
592
673
|
totalInput = tokens(u.input_tokens),
|
|
593
674
|
output = tokens(u.output_tokens),
|
|
594
|
-
input = anthropic ? totalInput : totalInput - read - write
|
|
675
|
+
input = anthropic ? totalInput : totalInput - read - write,
|
|
676
|
+
// Anthropic splits writes by lifetime; an unsplit write is the default five minutes.
|
|
677
|
+
write1h =
|
|
678
|
+
anthropic && object(u.cache_creation)
|
|
679
|
+
? tokens(u.cache_creation.ephemeral_1h_input_tokens ?? 0)
|
|
680
|
+
: 0;
|
|
595
681
|
need(
|
|
596
|
-
input >= 0 &&
|
|
682
|
+
input >= 0 &&
|
|
683
|
+
write1h <= write &&
|
|
684
|
+
(!anthropic || entry.cache !== "none" || (read === 0 && write === 0)) &&
|
|
685
|
+
(!anthropic || entry.cache === "1h" || write1h === 0),
|
|
597
686
|
"cache_usage_mismatch",
|
|
598
687
|
);
|
|
599
688
|
const rates = entry.model.cost;
|
|
@@ -601,7 +690,9 @@ export function decode(
|
|
|
601
690
|
input: (input * rates.input) / 1e6,
|
|
602
691
|
output: (output * rates.output) / 1e6,
|
|
603
692
|
cacheRead: (read * rates.cacheRead) / 1e6,
|
|
604
|
-
cacheWrite:
|
|
693
|
+
cacheWrite:
|
|
694
|
+
((write - write1h) * rates.cacheWrite + write1h * entry.cacheWrite1h) /
|
|
695
|
+
1e6,
|
|
605
696
|
total: 0,
|
|
606
697
|
};
|
|
607
698
|
cost.total = cost.input + cost.output + cost.cacheRead + cost.cacheWrite;
|