@rubicon-caliga/agent-sdk 0.1.2 → 0.1.4

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.
@@ -1,18 +1,30 @@
1
- import type { StartSessionResponse, StreamPaymentRequest } from "@rubicon-caliga/core";
1
+ import type { StartSessionResponse, StreamAuthorizationRequest, StreamPaymentRequest } from "@rubicon-caliga/core";
2
2
  /**
3
- * Produces the payment payload for exactly one word. Called once per word by the
4
- * SDK's read loop application developers never assemble payments themselves.
3
+ * Produces Circle / Arc authorization payloads for Rubicon reads. Preferred
4
+ * engines authorize a whole session once; fallback engines authorize chunks.
5
+ * Legacy one-word payloads remain supported for older gateways and tests.
5
6
  */
6
7
  export interface AgentPaymentEngine {
8
+ createSessionAuthorization?(session: StartSessionResponse): Promise<StreamAuthorizationRequest>;
9
+ createChunkPayment?(session: StartSessionResponse, input: {
10
+ nextSequence: number;
11
+ maxWords: number;
12
+ }): Promise<StreamPaymentRequest>;
13
+ /** @deprecated Compatibility path for one-word x402 gateways. */
7
14
  createWordPayment(session: StartSessionResponse): Promise<StreamPaymentRequest>;
8
15
  }
9
16
  /**
10
- * Development engine. Declares the one-word amount without settling real funds,
11
- * for use against a dev-mode gateway. NOT for production.
17
+ * Development engine. Declares the authorized amount without settling real
18
+ * funds, for use against a dev-mode gateway. NOT for production.
12
19
  */
13
20
  export declare class StaticPaymentEngine implements AgentPaymentEngine {
14
21
  private readonly network;
15
22
  constructor(network?: string);
23
+ createSessionAuthorization(session: StartSessionResponse): Promise<StreamAuthorizationRequest>;
24
+ createChunkPayment(session: StartSessionResponse, input: {
25
+ nextSequence: number;
26
+ maxWords: number;
27
+ }): Promise<StreamPaymentRequest>;
16
28
  createWordPayment(session: StartSessionResponse): Promise<StreamPaymentRequest>;
17
29
  }
18
30
  //# sourceMappingURL=payment-engine.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"payment-engine.d.ts","sourceRoot":"","sources":["../src/payment-engine.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAEvF;;;GAGG;AACH,MAAM,WAAW,kBAAkB;IACjC,iBAAiB,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAC;CACjF;AAED;;;GAGG;AACH,qBAAa,mBAAoB,YAAW,kBAAkB;IAChD,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,SAAmB;IAEjD,iBAAiB,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,oBAAoB,CAAC;CAWtF"}
1
+ {"version":3,"file":"payment-engine.d.ts","sourceRoot":"","sources":["../src/payment-engine.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,0BAA0B,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAEnH;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IACjC,0BAA0B,CAAC,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,0BAA0B,CAAC,CAAC;IAChG,kBAAkB,CAAC,CAAC,OAAO,EAAE,oBAAoB,EAAE,KAAK,EAAE;QAAE,YAAY,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACrI,iEAAiE;IACjE,iBAAiB,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAC;CACjF;AAED;;;GAGG;AACH,qBAAa,mBAAoB,YAAW,kBAAkB;IAChD,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,SAAmB;IAEjD,0BAA0B,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,0BAA0B,CAAC;IAa9F,kBAAkB,CAAC,OAAO,EAAE,oBAAoB,EAAE,KAAK,EAAE;QAAE,YAAY,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,oBAAoB,CAAC;IAiBnI,iBAAiB,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,oBAAoB,CAAC;CAWtF"}
@@ -1,12 +1,40 @@
1
1
  /**
2
- * Development engine. Declares the one-word amount without settling real funds,
3
- * for use against a dev-mode gateway. NOT for production.
2
+ * Development engine. Declares the authorized amount without settling real
3
+ * funds, for use against a dev-mode gateway. NOT for production.
4
4
  */
5
5
  export class StaticPaymentEngine {
6
6
  network;
7
7
  constructor(network = "eip155:5042002") {
8
8
  this.network = network;
9
9
  }
10
+ async createSessionAuthorization(session) {
11
+ return {
12
+ authorizationPayload: {
13
+ scheme: "development-static",
14
+ network: this.network,
15
+ sessionId: session.sessionId,
16
+ amountAtomic: session.authorizationRequired?.maxAuthorizedAtomic ?? session.maxArticlePriceAtomic,
17
+ meteringUnit: "word",
18
+ authorizationMode: "session",
19
+ },
20
+ };
21
+ }
22
+ async createChunkPayment(session, input) {
23
+ const amountAtomic = BigInt(session.wordPaymentAtomic) * BigInt(input.maxWords);
24
+ return {
25
+ paymentPayload: {
26
+ scheme: "development-static",
27
+ network: this.network,
28
+ sessionId: session.sessionId,
29
+ amountAtomic: `${amountAtomic}`,
30
+ meteringUnit: "word",
31
+ authorizationMode: "chunk",
32
+ maxWords: input.maxWords,
33
+ nextSequence: input.nextSequence,
34
+ },
35
+ maxWords: input.maxWords,
36
+ };
37
+ }
10
38
  async createWordPayment(session) {
11
39
  return {
12
40
  paymentPayload: {
@@ -1 +1 @@
1
- {"version":3,"file":"payment-engine.js","sourceRoot":"","sources":["../src/payment-engine.ts"],"names":[],"mappings":"AAUA;;;GAGG;AACH,MAAM,OAAO,mBAAmB;IACD;IAA7B,YAA6B,UAAU,gBAAgB;QAA1B,YAAO,GAAP,OAAO,CAAmB;IAAG,CAAC;IAE3D,KAAK,CAAC,iBAAiB,CAAC,OAA6B;QACnD,OAAO;YACL,cAAc,EAAE;gBACd,MAAM,EAAE,oBAAoB;gBAC5B,OAAO,EAAE,IAAI,CAAC,OAAO;gBACrB,SAAS,EAAE,OAAO,CAAC,SAAS;gBAC5B,YAAY,EAAE,OAAO,CAAC,iBAAiB;gBACvC,YAAY,EAAE,MAAM;aACrB;SACF,CAAC;IACJ,CAAC;CACF"}
1
+ {"version":3,"file":"payment-engine.js","sourceRoot":"","sources":["../src/payment-engine.ts"],"names":[],"mappings":"AAcA;;;GAGG;AACH,MAAM,OAAO,mBAAmB;IACD;IAA7B,YAA6B,UAAU,gBAAgB;QAA1B,YAAO,GAAP,OAAO,CAAmB;IAAG,CAAC;IAE3D,KAAK,CAAC,0BAA0B,CAAC,OAA6B;QAC5D,OAAO;YACL,oBAAoB,EAAE;gBACpB,MAAM,EAAE,oBAAoB;gBAC5B,OAAO,EAAE,IAAI,CAAC,OAAO;gBACrB,SAAS,EAAE,OAAO,CAAC,SAAS;gBAC5B,YAAY,EAAE,OAAO,CAAC,qBAAqB,EAAE,mBAAmB,IAAI,OAAO,CAAC,qBAAqB;gBACjG,YAAY,EAAE,MAAM;gBACpB,iBAAiB,EAAE,SAAS;aAC7B;SACF,CAAC;IACJ,CAAC;IAED,KAAK,CAAC,kBAAkB,CAAC,OAA6B,EAAE,KAAiD;QACvG,MAAM,YAAY,GAAG,MAAM,CAAC,OAAO,CAAC,iBAAiB,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;QAChF,OAAO;YACL,cAAc,EAAE;gBACd,MAAM,EAAE,oBAAoB;gBAC5B,OAAO,EAAE,IAAI,CAAC,OAAO;gBACrB,SAAS,EAAE,OAAO,CAAC,SAAS;gBAC5B,YAAY,EAAE,GAAG,YAAY,EAAE;gBAC/B,YAAY,EAAE,MAAM;gBACpB,iBAAiB,EAAE,OAAO;gBAC1B,QAAQ,EAAE,KAAK,CAAC,QAAQ;gBACxB,YAAY,EAAE,KAAK,CAAC,YAAY;aACjC;YACD,QAAQ,EAAE,KAAK,CAAC,QAAQ;SACzB,CAAC;IACJ,CAAC;IAED,KAAK,CAAC,iBAAiB,CAAC,OAA6B;QACnD,OAAO;YACL,cAAc,EAAE;gBACd,MAAM,EAAE,oBAAoB;gBAC5B,OAAO,EAAE,IAAI,CAAC,OAAO;gBACrB,SAAS,EAAE,OAAO,CAAC,SAAS;gBAC5B,YAAY,EAAE,OAAO,CAAC,iBAAiB;gBACvC,YAAY,EAAE,MAAM;aACrB;SACF,CAAC;IACJ,CAAC;CACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubicon-caliga/agent-sdk",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Client SDK for autonomous agents consuming per-word article streams via Rubicon x402.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "@x402/evm": "^2.15.0",
32
32
  "eventsource": "^4.0.0",
33
33
  "viem": "^2.52.2",
34
- "@rubicon-caliga/core": "0.1.0"
34
+ "@rubicon-caliga/core": "0.1.2"
35
35
  },
36
36
  "scripts": {
37
37
  "build": "tsc -p tsconfig.json",
@@ -9,6 +9,18 @@ const paymentEngine: AgentPaymentEngine = {
9
9
  },
10
10
  };
11
11
 
12
+ const chunkPaymentEngine: AgentPaymentEngine = {
13
+ async createWordPayment() {
14
+ throw new Error("word fallback should not be used");
15
+ },
16
+ async createChunkPayment(_session, input) {
17
+ return {
18
+ paymentPayload: { amountAtomic: `${input.maxWords}` },
19
+ maxWords: input.maxWords,
20
+ };
21
+ },
22
+ };
23
+
12
24
  test("run receipt preserves Gateway settlement receipt fields", async () => {
13
25
  const fetcher = (async (input: Parameters<typeof fetch>[0]) => {
14
26
  const url = String(input);
@@ -80,6 +92,218 @@ test("run receipt preserves Gateway settlement receipt fields", async () => {
80
92
  assert.equal(receipt.network, "eip155:5042002");
81
93
  });
82
94
 
95
+ test("run can consume chunk stream while preserving per-word receipt fields", async () => {
96
+ const fetcher = (async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
97
+ const url = String(input);
98
+ if (url.endsWith("/v1/sessions")) {
99
+ return jsonResponse({
100
+ sessionId: "session_1",
101
+ state: "active",
102
+ article: article(3),
103
+ navigation: navigation(),
104
+ pricePerWordAtomic: "1",
105
+ maxArticlePriceAtomic: "10",
106
+ conversationId: "conversation_1",
107
+ wordPaymentAtomic: "1",
108
+ gatewayFeeBps: 0,
109
+ paymentRequired: { accepts: [{ amount: "1" }] },
110
+ expiresAt: "2026-06-18T12:00:00.000Z",
111
+ wordsPaid: 0,
112
+ wordsDelivered: 0,
113
+ paidAtomic: "0",
114
+ });
115
+ }
116
+ if (url.endsWith("/v1/sessions/session_1/stream")) {
117
+ const body = JSON.parse(String(init?.body)) as { maxWords: number };
118
+ assert.equal(body.maxWords, 3);
119
+ return jsonResponse({
120
+ accepted: true,
121
+ words: [
122
+ { sequence: 0, word: "Rubicon", priceAtomic: "1" },
123
+ { sequence: 1, word: "streams", priceAtomic: "1" },
124
+ { sequence: 2, word: "chunks", priceAtomic: "1" },
125
+ ],
126
+ text: "Rubicon streams chunks",
127
+ wordsPaid: 3,
128
+ wordsDelivered: 3,
129
+ paidAtomic: "3",
130
+ completed: true,
131
+ settlementIds: ["settlement_chunk"],
132
+ payment: {
133
+ ...payment(0, "Rubicon streams chunks"),
134
+ amountAtomic: "3",
135
+ bundleSequence: 0,
136
+ wordsDelivered: 3,
137
+ pricePerWordAtomic: "1",
138
+ text: "Rubicon streams chunks",
139
+ },
140
+ });
141
+ }
142
+ throw new Error(`Unexpected fetch: ${url}`);
143
+ }) as typeof fetch;
144
+
145
+ const client = new RubiconClient({
146
+ baseUrl: "http://rubicon.test",
147
+ paymentEngine: chunkPaymentEngine,
148
+ fetch: fetcher,
149
+ });
150
+
151
+ const receipt = await client.run({
152
+ articleId: "article_1",
153
+ maxSpendAtomic: "10",
154
+ chunkWords: 3,
155
+ });
156
+
157
+ assert.equal(receipt.text, "Rubicon streams chunks");
158
+ assert.equal(receipt.wordsRead, 3);
159
+ assert.equal(receipt.amountPaidAtomic, "3");
160
+ assert.equal(receipt.payments.length, 1);
161
+ assert.equal(receipt.payments[0]?.amountAtomic, "3");
162
+ assert.equal(receipt.payments[0]?.wordsDelivered, 3);
163
+ assert.deepEqual(receipt.settlementIds, ["settlement_chunk"]);
164
+ });
165
+
166
+ test("read defaults to bundled mode and clamps bundle size to remaining article words", async () => {
167
+ const fetcher = (async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
168
+ const url = String(input);
169
+ if (url.endsWith("/v1/sessions")) {
170
+ return jsonResponse({
171
+ sessionId: "session_1",
172
+ state: "active",
173
+ article: article(15),
174
+ navigation: navigation(),
175
+ pricePerWordAtomic: "1",
176
+ maxArticlePriceAtomic: "15",
177
+ conversationId: "conversation_1",
178
+ wordPaymentAtomic: "1",
179
+ gatewayFeeBps: 0,
180
+ paymentRequired: { accepts: [{ amount: "1" }] },
181
+ expiresAt: "2026-06-18T12:00:00.000Z",
182
+ wordsPaid: 0,
183
+ wordsDelivered: 0,
184
+ paidAtomic: "0",
185
+ });
186
+ }
187
+ if (url.endsWith("/v1/sessions/session_1/stream")) {
188
+ const body = JSON.parse(String(init?.body)) as { maxWords: number };
189
+ assert.equal(body.maxWords, 15);
190
+ return jsonResponse({
191
+ accepted: true,
192
+ words: Array.from({ length: 15 }, (_, sequence) => ({ sequence, word: `w${sequence + 1}`, priceAtomic: "1" })),
193
+ text: Array.from({ length: 15 }, (_, index) => `w${index + 1}`).join(" "),
194
+ wordsPaid: 15,
195
+ wordsDelivered: 15,
196
+ paidAtomic: "15",
197
+ completed: true,
198
+ payment: { ...payment(0, "bundle"), amountAtomic: "15", wordsDelivered: 15, pricePerWordAtomic: "1" },
199
+ });
200
+ }
201
+ throw new Error(`Unexpected fetch: ${url}`);
202
+ }) as typeof fetch;
203
+
204
+ const client = new RubiconClient({ baseUrl: "http://rubicon.test", paymentEngine: { ...chunkPaymentEngine, createWordPayment: paymentEngine.createWordPayment }, fetch: fetcher });
205
+ const events = [];
206
+ for await (const event of client.read({ articleId: "article_1", maxSpendAtomic: "100" })) {
207
+ events.push(event.type);
208
+ }
209
+ assert.deepEqual(events, ["session.started", "article.bundle", "article.usage", "article.completed"]);
210
+ });
211
+
212
+ test("bundled mode clamps bundle size to budget and max words", async () => {
213
+ const seenMaxWords: number[] = [];
214
+ const fetcher = (async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
215
+ const url = String(input);
216
+ if (url.endsWith("/v1/sessions")) {
217
+ return jsonResponse({
218
+ sessionId: "session_1",
219
+ state: "active",
220
+ article: article(100),
221
+ navigation: navigation(),
222
+ pricePerWordAtomic: "2",
223
+ maxArticlePriceAtomic: "200",
224
+ conversationId: "conversation_1",
225
+ wordPaymentAtomic: "2",
226
+ gatewayFeeBps: 0,
227
+ paymentRequired: { accepts: [{ amount: "2" }] },
228
+ expiresAt: "2026-06-18T12:00:00.000Z",
229
+ wordsPaid: 0,
230
+ wordsDelivered: 0,
231
+ paidAtomic: "0",
232
+ });
233
+ }
234
+ if (url.endsWith("/v1/sessions/session_1/stream")) {
235
+ const body = JSON.parse(String(init?.body)) as { maxWords: number };
236
+ seenMaxWords.push(body.maxWords);
237
+ return jsonResponse({
238
+ accepted: true,
239
+ words: Array.from({ length: body.maxWords }, (_, sequence) => ({ sequence, word: `w${sequence + 1}`, priceAtomic: "2" })),
240
+ text: "ok",
241
+ wordsPaid: body.maxWords,
242
+ wordsDelivered: body.maxWords,
243
+ paidAtomic: `${body.maxWords * 2}`,
244
+ completed: true,
245
+ });
246
+ }
247
+ throw new Error(`Unexpected fetch: ${url}`);
248
+ }) as typeof fetch;
249
+
250
+ const client = new RubiconClient({
251
+ baseUrl: "http://rubicon.test",
252
+ paymentEngine: { ...chunkPaymentEngine, createWordPayment: paymentEngine.createWordPayment },
253
+ fetch: fetcher,
254
+ });
255
+ await client.run({ articleId: "article_1", maxSpendAtomic: "12", maxWords: 10 });
256
+ assert.deepEqual(seenMaxWords, [6]);
257
+ });
258
+
259
+ test("explicit word stream mode keeps legacy word events", async () => {
260
+ const fetcher = (async (input: Parameters<typeof fetch>[0]) => {
261
+ const url = String(input);
262
+ if (url.endsWith("/v1/sessions")) {
263
+ return jsonResponse({
264
+ sessionId: "session_1",
265
+ state: "active",
266
+ article: article(1),
267
+ navigation: navigation(),
268
+ pricePerWordAtomic: "1",
269
+ maxArticlePriceAtomic: "1",
270
+ conversationId: "conversation_1",
271
+ wordPaymentAtomic: "1",
272
+ gatewayFeeBps: 0,
273
+ paymentRequired: { accepts: [{ amount: "1" }] },
274
+ expiresAt: "2026-06-18T12:00:00.000Z",
275
+ wordsPaid: 0,
276
+ wordsDelivered: 0,
277
+ paidAtomic: "0",
278
+ });
279
+ }
280
+ if (url.endsWith("/v1/sessions/session_1/payments")) {
281
+ return jsonResponse({
282
+ accepted: true,
283
+ sequence: 0,
284
+ word: "Rubicon",
285
+ priceAtomic: "1",
286
+ wordsPaid: 1,
287
+ wordsDelivered: 1,
288
+ paidAtomic: "1",
289
+ completed: true,
290
+ });
291
+ }
292
+ throw new Error(`Unexpected fetch: ${url}`);
293
+ }) as typeof fetch;
294
+
295
+ const client = new RubiconClient({
296
+ baseUrl: "http://rubicon.test",
297
+ paymentEngine: { ...chunkPaymentEngine, createWordPayment: paymentEngine.createWordPayment },
298
+ fetch: fetcher,
299
+ });
300
+ const events = [];
301
+ for await (const event of client.read({ articleId: "article_1", maxSpendAtomic: "10", streamMode: "word" })) {
302
+ events.push(event.type);
303
+ }
304
+ assert.deepEqual(events, ["session.started", "article.word", "article.usage", "article.completed"]);
305
+ });
306
+
83
307
  function jsonResponse(body: unknown): Response {
84
308
  return new Response(JSON.stringify(body), {
85
309
  status: 200,
@@ -87,7 +311,7 @@ function jsonResponse(body: unknown): Response {
87
311
  });
88
312
  }
89
313
 
90
- function article() {
314
+ function article(totalWords = 1) {
91
315
  return {
92
316
  articleId: "article_1",
93
317
  creatorId: "creator_1",
@@ -95,9 +319,9 @@ function article() {
95
319
  title: "Title",
96
320
  author: "Author",
97
321
  state: "published",
98
- totalWords: 1,
322
+ totalWords,
99
323
  pricePerWordAtomic: "1",
100
- maxArticlePriceAtomic: "1",
324
+ maxArticlePriceAtomic: `${totalWords}`,
101
325
  sections: [],
102
326
  };
103
327
  }
@@ -116,3 +340,17 @@ function navigation() {
116
340
  stopConditions: [],
117
341
  };
118
342
  }
343
+
344
+ function payment(sequence: number, word: string) {
345
+ return {
346
+ paymentId: `payment_${sequence}`,
347
+ sessionId: "session_1",
348
+ articleId: "article_1",
349
+ sequence,
350
+ meteringUnit: "word",
351
+ amountAtomic: "1",
352
+ currency: "USDC",
353
+ settledAt: "2026-06-18T12:00:00.000Z",
354
+ word,
355
+ };
356
+ }
@@ -7,6 +7,8 @@ import type {
7
7
  StartConversationResponse,
8
8
  StartSessionRequest,
9
9
  StartSessionResponse,
10
+ StreamChunkResponse,
11
+ StreamMode,
10
12
  StreamPaymentRequest,
11
13
  StreamPaymentResponse,
12
14
  WordPaymentReceipt,
@@ -63,6 +65,28 @@ export type RubiconReadEvent =
63
65
  payment?: WordPaymentReceipt;
64
66
  text: string;
65
67
  }
68
+ | {
69
+ type: "article.bundle";
70
+ bundleSequence: number;
71
+ words: Array<{ sequence: number; word: string; priceAtomic: `${bigint}`; payment?: WordPaymentReceipt }>;
72
+ text: string;
73
+ bundleText: string;
74
+ wordCount: number;
75
+ amountAtomic: `${bigint}`;
76
+ payment?: WordPaymentReceipt;
77
+ wordsRead: number;
78
+ amountPaidAtomic: `${bigint}`;
79
+ completed: boolean;
80
+ }
81
+ /** @deprecated Use article.bundle. */
82
+ | {
83
+ type: "article.chunk";
84
+ words: Array<{ sequence: number; word: string; priceAtomic: `${bigint}`; payment?: WordPaymentReceipt }>;
85
+ text: string;
86
+ wordsRead: number;
87
+ amountPaidAtomic: `${bigint}`;
88
+ completed: boolean;
89
+ }
66
90
  | { type: "article.usage"; wordsPaid: number; wordsDelivered: number; paidAtomic: `${bigint}` }
67
91
  | { type: "article.completed"; receipt: ReadReceipt }
68
92
  | { type: "article.error"; message: string };
@@ -76,6 +100,10 @@ export interface ReadOptions {
76
100
  maxSpendAtomic?: `${bigint}`;
77
101
  budget?: Budget;
78
102
  maxWords?: number;
103
+ /** Number of words to authorize and deliver per gateway round trip when supported. */
104
+ chunkWords?: number;
105
+ /** Default is bundled. Use word for legacy one-word events/payments. */
106
+ streamMode?: StreamMode;
79
107
  /** Return true to stop reading once enough information has been collected. */
80
108
  stopWhen?: (state: {
81
109
  text: string;
@@ -92,8 +120,8 @@ export interface RunOptions extends ReadOptions {
92
120
 
93
121
  /**
94
122
  * High-level buyer-agent client for Rubicon. `read()` runs the entire
95
- * pay -> word -> usage loop one word at a time until a stop condition is met,
96
- * so application developers never send a payment for every word themselves.
123
+ * authorize -> word -> usage loop until a stop condition is met, so application
124
+ * developers never drive a payment flow for every word themselves.
97
125
  */
98
126
  export class RubiconClient {
99
127
  private readonly fetcher: typeof fetch;
@@ -170,6 +198,18 @@ export class RubiconClient {
170
198
  return response.json() as Promise<StreamPaymentResponse>;
171
199
  }
172
200
 
201
+ async streamChunk(sessionId: string, payment: StreamPaymentRequest): Promise<StreamChunkResponse> {
202
+ const response = await this.fetcher(`${this.baseUrl}/v1/sessions/${sessionId}/stream`, {
203
+ method: "POST",
204
+ headers: this.headers({ "content-type": "application/json" }),
205
+ body: JSON.stringify(payment),
206
+ });
207
+ if (!response.ok) {
208
+ throw new Error(`Chunk stream rejected: ${response.status} ${await response.text()}`);
209
+ }
210
+ return response.json() as Promise<StreamChunkResponse>;
211
+ }
212
+
173
213
  async abort(sessionId: string, reason = "agent_cancelled"): Promise<void> {
174
214
  await this.fetcher(`${this.baseUrl}/v1/sessions/${sessionId}/abort`, {
175
215
  method: "POST",
@@ -210,6 +250,14 @@ export class RubiconClient {
210
250
  wordsRead: event.wordsRead,
211
251
  amountPaidAtomic: event.amountPaidAtomic,
212
252
  });
253
+ } else if (event.type === "article.bundle") {
254
+ for (const entry of event.words) {
255
+ await options.onWord?.(entry.word, {
256
+ text: event.text,
257
+ wordsRead: event.wordsRead,
258
+ amountPaidAtomic: event.amountPaidAtomic,
259
+ });
260
+ }
213
261
  }
214
262
  if (event.type === "article.completed") {
215
263
  receipt = event.receipt;
@@ -222,8 +270,8 @@ export class RubiconClient {
222
270
  }
223
271
 
224
272
  /**
225
- * Read an article one paid word at a time. Yields seller messages, paid words,
226
- * running usage, and a final completion event carrying the receipt.
273
+ * Read an article with word-level metering. Yields seller messages, paid
274
+ * words, running usage, and a final completion event carrying the receipt.
227
275
  */
228
276
  async *read(options: ReadOptions): AsyncGenerator<RubiconReadEvent, ReadReceipt> {
229
277
  const budget: Budget =
@@ -274,6 +322,11 @@ export class RubiconClient {
274
322
  const transactionHashes: string[] = [];
275
323
  const settlementIds: string[] = [];
276
324
  const payments: WordPaymentReceipt[] = [];
325
+ const streamMode = options.streamMode ?? "bundled";
326
+ const bundleWords = normalizeBundleWords(options.chunkWords);
327
+ const useBundles = streamMode === "bundled" && typeof this.paymentEngine.createChunkPayment === "function";
328
+ const selectedWordLimit = selectedRangeWordLimit(session, sectionId);
329
+ let bundleSequence = 0;
277
330
  let stopReason: ReadReceipt["stopReason"] = "article_completed";
278
331
  let completed = false;
279
332
 
@@ -308,6 +361,84 @@ export class RubiconClient {
308
361
  break;
309
362
  }
310
363
 
364
+ if (useBundles) {
365
+ const remainingRequestedWords = options.maxWords === undefined ? Number.MAX_SAFE_INTEGER : Math.max(0, options.maxWords - wordsRead);
366
+ const affordableWords = Number((budgetAtomic - amountPaid) / wordPaymentAtomic);
367
+ const remainingArticleWords =
368
+ selectedWordLimit === undefined ? Number.MAX_SAFE_INTEGER : Math.max(0, selectedWordLimit - wordsRead);
369
+ const maxWords = Math.min(bundleWords, remainingRequestedWords, affordableWords, remainingArticleWords);
370
+ if (maxWords < 1) {
371
+ stopReason = selectedWordLimit !== undefined && wordsRead >= selectedWordLimit ? "article_completed" : "budget_reached";
372
+ break;
373
+ }
374
+ const payment = await this.paymentEngine.createChunkPayment!(session, {
375
+ nextSequence: wordsRead,
376
+ maxWords,
377
+ });
378
+ const idempotencyKey = `${session.sessionId}:${wordsRead}:${maxWords}`;
379
+ let result: StreamChunkResponse;
380
+ try {
381
+ result = await this.streamChunk(session.sessionId, { ...payment, idempotencyKey, maxWords });
382
+ } catch (error) {
383
+ yield { type: "article.error", message: error instanceof Error ? error.message : String(error) };
384
+ stopReason = "aborted";
385
+ break;
386
+ }
387
+ if (result.words.length === 0 && result.completed) {
388
+ completed = true;
389
+ stopReason = "article_completed";
390
+ const receipt = makeReceipt();
391
+ yield { type: "article.completed", receipt };
392
+ return receipt;
393
+ }
394
+ if (result.payment) {
395
+ payments.push(result.payment);
396
+ } else {
397
+ for (const entry of result.words) {
398
+ if (entry.payment) {
399
+ payments.push(entry.payment);
400
+ }
401
+ }
402
+ }
403
+ const previousAmountPaid = amountPaid;
404
+ const bundleText = result.text || result.words.map((entry) => entry.word).join(" ");
405
+ for (const entry of result.words) {
406
+ text = text ? `${text} ${entry.word}` : entry.word;
407
+ }
408
+ wordsRead = result.wordsDelivered;
409
+ amountPaid = BigInt(result.paidAtomic);
410
+ transactionHashes.push(...(result.transactionHashes ?? (result.transactionHash ? [result.transactionHash] : [])));
411
+ settlementIds.push(...(result.settlementIds ?? (result.settlementId ? [result.settlementId] : [])));
412
+ yield {
413
+ type: "article.bundle",
414
+ bundleSequence,
415
+ words: result.words,
416
+ text,
417
+ bundleText,
418
+ wordCount: result.words.length,
419
+ amountAtomic: result.payment?.amountAtomic ?? `${amountPaid - previousAmountPaid}`,
420
+ payment: result.payment,
421
+ wordsRead,
422
+ amountPaidAtomic: `${amountPaid}`,
423
+ completed: result.completed,
424
+ };
425
+ bundleSequence += 1;
426
+ yield {
427
+ type: "article.usage",
428
+ wordsPaid: result.wordsPaid,
429
+ wordsDelivered: result.wordsDelivered,
430
+ paidAtomic: result.paidAtomic,
431
+ };
432
+ if (result.completed) {
433
+ completed = true;
434
+ stopReason = "article_completed";
435
+ const receipt = makeReceipt();
436
+ yield { type: "article.completed", receipt };
437
+ return receipt;
438
+ }
439
+ continue;
440
+ }
441
+
311
442
  const payment = await this.paymentEngine.createWordPayment(session);
312
443
  // Idempotency key ties this payment to the specific next word; safe retries.
313
444
  const idempotencyKey = `${session.sessionId}:${wordsRead}`;
@@ -391,3 +522,16 @@ export class RubiconClient {
391
522
 
392
523
  /** Backwards-compatible alias. */
393
524
  export const AgentClient = RubiconClient;
525
+
526
+ function normalizeBundleWords(chunkWords: number | undefined): number {
527
+ if (chunkWords === undefined) return 32;
528
+ if (!Number.isInteger(chunkWords) || chunkWords < 1) return 1;
529
+ return Math.min(chunkWords, 256);
530
+ }
531
+
532
+ function selectedRangeWordLimit(session: StartSessionResponse, sectionId: string | undefined): number | undefined {
533
+ if (!sectionId || sectionId === "full-article") {
534
+ return session.article.totalWords;
535
+ }
536
+ return session.navigation.sections.find((section) => section.sectionId === sectionId)?.wordCount;
537
+ }