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.
- package/CHANGELOG.md +22 -0
- package/dist/Method.d.ts +2 -0
- package/dist/Method.d.ts.map +1 -1
- package/dist/Method.js.map +1 -1
- package/dist/client/Mppx.d.ts +2 -0
- package/dist/client/Mppx.d.ts.map +1 -1
- package/dist/client/Mppx.js +4 -1
- package/dist/client/Mppx.js.map +1 -1
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts +4 -0
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +43 -5
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/server/Mppx.d.ts +45 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +139 -16
- package/dist/server/Mppx.js.map +1 -1
- package/dist/stripe/server/Charge.d.ts.map +1 -1
- package/dist/stripe/server/Charge.js +8 -1
- package/dist/stripe/server/Charge.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +15 -4
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Session.d.ts +39 -38
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +14 -24
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/server/internal/request-body.d.ts +8 -0
- package/dist/tempo/server/internal/request-body.d.ts.map +1 -0
- package/dist/tempo/server/internal/request-body.js +27 -0
- package/dist/tempo/server/internal/request-body.js.map +1 -0
- package/dist/tempo/server/internal/transport.d.ts.map +1 -1
- package/dist/tempo/server/internal/transport.js +6 -17
- package/dist/tempo/server/internal/transport.js.map +1 -1
- package/package.json +1 -1
- package/src/Method.ts +2 -0
- package/src/cli/cli.test.ts +15 -7
- package/src/client/Mppx.ts +11 -2
- package/src/client/index.ts +1 -0
- package/src/client/internal/Fetch.browser.test.ts +58 -0
- package/src/client/internal/Fetch.test.ts +173 -0
- package/src/client/internal/Fetch.ts +62 -3
- package/src/server/Mppx.test-d.ts +36 -0
- package/src/server/Mppx.test.ts +1073 -1
- package/src/server/Mppx.ts +241 -22
- package/src/server/Transport.test.ts +2 -1
- package/src/stripe/server/Charge.ts +7 -1
- package/src/tempo/server/Charge.ts +15 -4
- package/src/tempo/server/Session.test.ts +68 -0
- package/src/tempo/server/Session.ts +15 -35
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/server/internal/request-body.test.ts +142 -0
- package/src/tempo/server/internal/request-body.ts +37 -0
- package/src/tempo/server/internal/transport.test.ts +126 -2
- 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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|