mppx 0.5.17 → 0.6.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/Method.d.ts +2 -0
  3. package/dist/Method.d.ts.map +1 -1
  4. package/dist/Method.js.map +1 -1
  5. package/dist/client/Mppx.d.ts +2 -0
  6. package/dist/client/Mppx.d.ts.map +1 -1
  7. package/dist/client/Mppx.js +4 -1
  8. package/dist/client/Mppx.js.map +1 -1
  9. package/dist/client/index.d.ts +1 -0
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +1 -0
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/client/internal/Fetch.d.ts +4 -0
  14. package/dist/client/internal/Fetch.d.ts.map +1 -1
  15. package/dist/client/internal/Fetch.js +43 -5
  16. package/dist/client/internal/Fetch.js.map +1 -1
  17. package/dist/server/Mppx.d.ts +45 -1
  18. package/dist/server/Mppx.d.ts.map +1 -1
  19. package/dist/server/Mppx.js +139 -16
  20. package/dist/server/Mppx.js.map +1 -1
  21. package/dist/stripe/server/Charge.d.ts.map +1 -1
  22. package/dist/stripe/server/Charge.js +8 -1
  23. package/dist/stripe/server/Charge.js.map +1 -1
  24. package/dist/tempo/server/Charge.d.ts.map +1 -1
  25. package/dist/tempo/server/Charge.js +15 -4
  26. package/dist/tempo/server/Charge.js.map +1 -1
  27. package/dist/tempo/server/Session.d.ts +39 -38
  28. package/dist/tempo/server/Session.d.ts.map +1 -1
  29. package/dist/tempo/server/Session.js +14 -24
  30. package/dist/tempo/server/Session.js.map +1 -1
  31. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  32. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  33. package/dist/tempo/server/internal/html.gen.js +1 -1
  34. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  35. package/dist/tempo/server/internal/request-body.d.ts +8 -0
  36. package/dist/tempo/server/internal/request-body.d.ts.map +1 -0
  37. package/dist/tempo/server/internal/request-body.js +27 -0
  38. package/dist/tempo/server/internal/request-body.js.map +1 -0
  39. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  40. package/dist/tempo/server/internal/transport.js +6 -17
  41. package/dist/tempo/server/internal/transport.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/Method.ts +2 -0
  44. package/src/cli/cli.test.ts +15 -7
  45. package/src/client/Mppx.ts +11 -2
  46. package/src/client/index.ts +1 -0
  47. package/src/client/internal/Fetch.browser.test.ts +58 -0
  48. package/src/client/internal/Fetch.test.ts +173 -0
  49. package/src/client/internal/Fetch.ts +62 -3
  50. package/src/server/Mppx.test-d.ts +36 -0
  51. package/src/server/Mppx.test.ts +1073 -1
  52. package/src/server/Mppx.ts +241 -22
  53. package/src/server/Transport.test.ts +2 -1
  54. package/src/stripe/server/Charge.ts +7 -1
  55. package/src/tempo/server/Charge.ts +15 -4
  56. package/src/tempo/server/Session.test.ts +68 -0
  57. package/src/tempo/server/Session.ts +15 -35
  58. package/src/tempo/server/internal/html.gen.ts +1 -1
  59. package/src/tempo/server/internal/request-body.test.ts +142 -0
  60. package/src/tempo/server/internal/request-body.ts +37 -0
  61. package/src/tempo/server/internal/transport.test.ts +126 -2
  62. package/src/tempo/server/internal/transport.ts +7 -19
@@ -0,0 +1,142 @@
1
+ import { describe, expect, test } from 'vp/test'
2
+
3
+ import {
4
+ captureRequestBodyProbe,
5
+ hasCapturedRequestBody,
6
+ isSessionContentRequest,
7
+ shouldChargePlainResponse,
8
+ } from './request-body.js'
9
+
10
+ describe('request-body', () => {
11
+ describe('hasCapturedRequestBody', () => {
12
+ test('returns true when the captured request recorded a body stream', () => {
13
+ expect(
14
+ hasCapturedRequestBody({
15
+ hasBody: true,
16
+ headers: new Headers(),
17
+ }),
18
+ ).toBe(true)
19
+ })
20
+
21
+ test('returns true when content-length is non-zero', () => {
22
+ expect(
23
+ hasCapturedRequestBody({
24
+ headers: new Headers({ 'content-length': '42' }),
25
+ }),
26
+ ).toBe(true)
27
+ })
28
+
29
+ test('returns true when transfer-encoding is present', () => {
30
+ expect(
31
+ hasCapturedRequestBody({
32
+ headers: new Headers({ 'transfer-encoding': 'chunked' }),
33
+ }),
34
+ ).toBe(true)
35
+ })
36
+
37
+ test('returns false for bodyless requests without framing headers', () => {
38
+ expect(
39
+ hasCapturedRequestBody({
40
+ hasBody: false,
41
+ headers: new Headers(),
42
+ }),
43
+ ).toBe(false)
44
+ })
45
+ })
46
+
47
+ describe('isSessionContentRequest', () => {
48
+ test('treats GET requests as content requests', () => {
49
+ expect(
50
+ isSessionContentRequest({
51
+ headers: new Headers(),
52
+ method: 'GET',
53
+ }),
54
+ ).toBe(true)
55
+ })
56
+
57
+ test('treats HEAD requests as management requests', () => {
58
+ expect(
59
+ isSessionContentRequest({
60
+ headers: new Headers(),
61
+ method: 'HEAD',
62
+ }),
63
+ ).toBe(false)
64
+ })
65
+
66
+ test('treats POST requests with a body stream and no content-length as content requests', () => {
67
+ expect(
68
+ isSessionContentRequest({
69
+ hasBody: true,
70
+ headers: new Headers(),
71
+ method: 'POST',
72
+ }),
73
+ ).toBe(true)
74
+ })
75
+
76
+ test('treats bodyless POST requests as management requests', () => {
77
+ expect(
78
+ isSessionContentRequest({
79
+ hasBody: false,
80
+ headers: new Headers(),
81
+ method: 'POST',
82
+ }),
83
+ ).toBe(false)
84
+ })
85
+ })
86
+
87
+ describe('shouldChargePlainResponse', () => {
88
+ test('does not charge close or topUp actions', () => {
89
+ const input = {
90
+ hasBody: true,
91
+ headers: new Headers(),
92
+ method: 'POST',
93
+ } as const
94
+
95
+ expect(shouldChargePlainResponse(input, { action: 'close' })).toBe(false)
96
+ expect(shouldChargePlainResponse(input, { action: 'topUp' })).toBe(false)
97
+ })
98
+
99
+ test('charges POST content requests detected via the body stream', () => {
100
+ expect(
101
+ shouldChargePlainResponse(
102
+ {
103
+ hasBody: true,
104
+ headers: new Headers(),
105
+ method: 'POST',
106
+ },
107
+ { action: 'voucher' },
108
+ ),
109
+ ).toBe(true)
110
+ })
111
+
112
+ test('does not charge bodyless POST management requests', () => {
113
+ expect(
114
+ shouldChargePlainResponse(
115
+ {
116
+ hasBody: false,
117
+ headers: new Headers(),
118
+ method: 'POST',
119
+ },
120
+ { action: 'voucher' },
121
+ ),
122
+ ).toBe(false)
123
+ })
124
+ })
125
+
126
+ describe('captureRequestBodyProbe', () => {
127
+ test('captures body presence from Request.body', () => {
128
+ const request = new Request('https://example.com', {
129
+ body: JSON.stringify({ prompt: 'hello' }),
130
+ method: 'POST',
131
+ })
132
+
133
+ const probe = captureRequestBodyProbe(request)
134
+ expect(request.headers.get('content-length')).toBeNull()
135
+ expect(probe).toEqual({
136
+ headers: request.headers,
137
+ hasBody: true,
138
+ method: 'POST',
139
+ })
140
+ })
141
+ })
142
+ })
@@ -0,0 +1,37 @@
1
+ import type * as Method from '../../../Method.js'
2
+ import type { SessionCredentialPayload } from '../../session/Types.js'
3
+
4
+ export type RequestBodyProbe = Pick<Method.CapturedRequest, 'headers' | 'hasBody' | 'method'>
5
+
6
+ export function captureRequestBodyProbe(input: Request): RequestBodyProbe {
7
+ return {
8
+ headers: input.headers,
9
+ hasBody: input.body !== null,
10
+ method: input.method,
11
+ }
12
+ }
13
+
14
+ export function hasCapturedRequestBody(
15
+ input: Pick<RequestBodyProbe, 'headers' | 'hasBody'>,
16
+ ): boolean {
17
+ const contentLength = input.headers.get('content-length')
18
+ const headerIndicatesBody =
19
+ (contentLength !== null && contentLength !== '0') || input.headers.has('transfer-encoding')
20
+
21
+ if (input.hasBody === true) return true
22
+ return headerIndicatesBody
23
+ }
24
+
25
+ export function isSessionContentRequest(input: RequestBodyProbe): boolean {
26
+ if (input.method === 'HEAD') return false
27
+ if (input.method !== 'POST') return true
28
+ return hasCapturedRequestBody(input)
29
+ }
30
+
31
+ export function shouldChargePlainResponse(
32
+ input: RequestBodyProbe,
33
+ payload: Partial<SessionCredentialPayload>,
34
+ ): boolean {
35
+ if (payload.action === 'close' || payload.action === 'topUp') return false
36
+ return isSessionContentRequest(input)
37
+ }
@@ -284,6 +284,90 @@ describe('sse transport', () => {
284
284
  expect(terminalReceipt.units).toBe(1)
285
285
  })
286
286
 
287
+ test('respondReceipt uses the verified route unitType instead of the echoed credential unitType', async () => {
288
+ const store = memoryStore()
289
+ await seedChannel(store, 10000000n)
290
+ const transport = sse({ store })
291
+ const request = makeAuthorizedRequest({ unitType: 'token' })
292
+ const credential = makeCredential({ unitType: 'request' })
293
+
294
+ async function* gen() {
295
+ yield 'hello'
296
+ yield 'world'
297
+ yield 'again'
298
+ }
299
+
300
+ const response = transport.respondReceipt({
301
+ credential,
302
+ envelope: {
303
+ capturedRequest: {
304
+ headers: new Headers(request.headers),
305
+ hasBody: request.body !== null,
306
+ method: request.method,
307
+ url: new URL(request.url),
308
+ },
309
+ challenge: makeChallenge({ unitType: 'token' }),
310
+ credential,
311
+ request: makeChallenge({ unitType: 'token' }).request,
312
+ },
313
+ input: request,
314
+ receipt: makeReceipt(),
315
+ response: gen(),
316
+ challengeId,
317
+ })
318
+
319
+ const body = await readResponseText(response)
320
+ const terminalReceipt = readTerminalReceipt(body)
321
+ const channel = await store.getChannel(channelId)
322
+
323
+ expect(channel!.spent).toBe(3000000n)
324
+ expect(channel!.units).toBe(3)
325
+ expect(terminalReceipt.spent).toBe('3000000')
326
+ expect(terminalReceipt.units).toBe(3)
327
+ })
328
+
329
+ test('respondReceipt uses the canonical challenge amount instead of the raw verified route amount', async () => {
330
+ const store = memoryStore()
331
+ await seedChannel(store, 10000000n)
332
+ const transport = sse({ store })
333
+ const request = makeAuthorizedRequest({ unitType: 'token' })
334
+ const credential = makeCredential({ unitType: 'token' })
335
+
336
+ const response = transport.respondReceipt({
337
+ credential,
338
+ envelope: {
339
+ capturedRequest: {
340
+ headers: new Headers(request.headers),
341
+ hasBody: request.body !== null,
342
+ method: request.method,
343
+ url: new URL(request.url),
344
+ },
345
+ challenge: credential.challenge,
346
+ credential,
347
+ request: {
348
+ ...credential.challenge.request,
349
+ amount: '1',
350
+ },
351
+ },
352
+ input: request,
353
+ receipt: makeReceipt(),
354
+ response: new Response('ok', {
355
+ headers: { 'Content-Type': 'text/plain' },
356
+ }),
357
+ challengeId,
358
+ })
359
+
360
+ expect(await response.text()).toBe('ok')
361
+
362
+ const receipt = deserializeSessionReceipt(response.headers.get('Payment-Receipt')!)
363
+ const channel = await store.getChannel(channelId)
364
+
365
+ expect(channel!.spent).toBe(1000000n)
366
+ expect(channel!.units).toBe(1)
367
+ expect(receipt.spent).toBe('1000000')
368
+ expect(receipt.units).toBe(1)
369
+ })
370
+
287
371
  test('respondReceipt with AsyncIterable and non-request unitType still charges per chunk', async () => {
288
372
  const store = memoryStore()
289
373
  await seedChannel(store, 10000000n)
@@ -565,7 +649,13 @@ describe('sse transport', () => {
565
649
  const store = memoryStore()
566
650
  await seedChannel(store, 10000000n)
567
651
  const transport = sse({ store })
568
- const request = makeAuthorizedRequest()
652
+ const request = new Request('https://test.example.com/session', {
653
+ body: JSON.stringify({ prompt: 'hello' }),
654
+ headers: makeAuthorizedRequest().headers,
655
+ method: 'POST',
656
+ })
657
+
658
+ expect(request.headers.get('content-length')).toBeNull()
569
659
 
570
660
  const plainResponse = new Response(JSON.stringify({ content: 'hello' }), {
571
661
  headers: { 'Content-Type': 'application/json' },
@@ -593,11 +683,45 @@ describe('sse transport', () => {
593
683
  expect(response.headers.get('Payment-Receipt')).toBeTruthy()
594
684
  })
595
685
 
686
+ test('respondReceipt with bodyless POST management request does not deduct from channel', async () => {
687
+ const store = memoryStore()
688
+ await seedChannel(store, 10000000n)
689
+ const transport = sse({ store })
690
+
691
+ const response = transport.respondReceipt({
692
+ credential: Credential.from({
693
+ challenge: makeChallenge(),
694
+ payload: {
695
+ action: 'voucher',
696
+ channelId,
697
+ cumulativeAmount: '1000000',
698
+ signature: '0xdeadbeef',
699
+ },
700
+ }),
701
+ input: new Request('https://test.example.com/session', { method: 'POST' }),
702
+ receipt: makeReceipt(),
703
+ response: new Response(JSON.stringify({ ok: true }), {
704
+ headers: { 'Content-Type': 'application/json' },
705
+ }),
706
+ challengeId,
707
+ })
708
+
709
+ expect(await response.text()).toBe('{"ok":true}')
710
+
711
+ const channel = await store.getChannel(channelId)
712
+ expect(channel!.spent).toBe(0n)
713
+ expect(channel!.units).toBe(0)
714
+ })
715
+
596
716
  test('respondReceipt with 204 content response still deducts from channel', async () => {
597
717
  const store = memoryStore()
598
718
  await seedChannel(store, 10000000n)
599
719
  const transport = sse({ store })
600
- const request = makeAuthorizedRequest()
720
+ const request = new Request('https://test.example.com/session', {
721
+ body: JSON.stringify({ prompt: 'hello' }),
722
+ headers: makeAuthorizedRequest().headers,
723
+ method: 'POST',
724
+ })
601
725
 
602
726
  const contentResponse = new Response(null, { status: 204 })
603
727
  const response = transport.respondReceipt({
@@ -11,6 +11,7 @@ import * as Transport from '../../../server/Transport.js'
11
11
  import * as ChannelStore from '../../session/ChannelStore.js'
12
12
  import * as Sse_core from '../../session/Sse.js'
13
13
  import type { SessionCredentialPayload, SessionReceipt } from '../../session/Types.js'
14
+ import { captureRequestBodyProbe, shouldChargePlainResponse } from './request-body.js'
14
15
 
15
16
  /** SSE transport with Tempo session controller. */
16
17
  export type Sse = Transport.Sse<Sse_core.SessionController>
@@ -43,8 +44,8 @@ export function sse(options: sse.Options & { store: ChannelStore.ChannelStore })
43
44
  captureRequest(request) {
44
45
  return (
45
46
  base.captureRequest?.(request) ?? {
46
- hasBody: request.body !== null,
47
47
  headers: new Headers(request.headers),
48
+ hasBody: request.body !== null,
48
49
  method: request.method,
49
50
  url: new URL(request.url),
50
51
  }
@@ -62,14 +63,14 @@ export function sse(options: sse.Options & { store: ChannelStore.ChannelStore })
62
63
  respondReceipt({ credential, envelope, receipt, response, challengeId, input }) {
63
64
  const verifiedCredential = envelope?.credential ?? credential
64
65
  const verifiedChallengeId = envelope?.challenge.id ?? challengeId
66
+ const verifiedRequest = envelope?.request ?? verifiedCredential.challenge.request
65
67
  const payload = verifiedCredential.payload as Partial<SessionCredentialPayload>
66
68
  if (!payload.channelId) throw new Error('No SSE context available')
69
+
67
70
  const channelId = payload.channelId
68
71
  const tickCost = BigInt(verifiedCredential.challenge.request.amount as string)
69
72
  const unitType =
70
- typeof verifiedCredential.challenge.request.unitType === 'string'
71
- ? verifiedCredential.challenge.request.unitType
72
- : undefined
73
+ typeof verifiedRequest.unitType === 'string' ? verifiedRequest.unitType : undefined
73
74
 
74
75
  // Auto-detect upstream SSE responses and parse them into an
75
76
  // AsyncIterable so they flow through the metered pipeline.
@@ -106,7 +107,8 @@ export function sse(options: sse.Options & { store: ChannelStore.ChannelStore })
106
107
  challengeId: verifiedChallengeId,
107
108
  })
108
109
 
109
- if (!shouldChargePlainResponse(input, payload)) {
110
+ const request = envelope?.capturedRequest ?? captureRequestBodyProbe(input)
111
+ if (!shouldChargePlainResponse(request, payload)) {
110
112
  return baseResponse
111
113
  }
112
114
 
@@ -274,17 +276,3 @@ function resolveMeteredGenerate(
274
276
  function isNullBodyStatus(status: number): boolean {
275
277
  return [101, 204, 205, 304].includes(status)
276
278
  }
277
-
278
- function shouldChargePlainResponse(
279
- input: Request,
280
- payload: Partial<SessionCredentialPayload>,
281
- ): boolean {
282
- if (payload.action === 'close' || payload.action === 'topUp') return false
283
- if (input.method !== 'POST') return true
284
-
285
- const contentLength = input.headers.get('content-length')
286
- if (contentLength !== null && contentLength !== '0') return true
287
- if (input.headers.has('transfer-encoding')) return true
288
-
289
- return false
290
- }