mppx 0.5.17 → 0.6.0
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 +16 -0
- 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 +38 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +70 -1
- 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 +4 -14
- package/dist/tempo/server/internal/transport.js.map +1 -1
- package/package.json +1 -1
- 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 +926 -1
- package/src/server/Mppx.ts +141 -2
- 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 +42 -2
- package/src/tempo/server/internal/transport.ts +4 -16
|
@@ -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
|
+
}
|
|
@@ -565,7 +565,13 @@ describe('sse transport', () => {
|
|
|
565
565
|
const store = memoryStore()
|
|
566
566
|
await seedChannel(store, 10000000n)
|
|
567
567
|
const transport = sse({ store })
|
|
568
|
-
const request =
|
|
568
|
+
const request = new Request('https://test.example.com/session', {
|
|
569
|
+
body: JSON.stringify({ prompt: 'hello' }),
|
|
570
|
+
headers: makeAuthorizedRequest().headers,
|
|
571
|
+
method: 'POST',
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
expect(request.headers.get('content-length')).toBeNull()
|
|
569
575
|
|
|
570
576
|
const plainResponse = new Response(JSON.stringify({ content: 'hello' }), {
|
|
571
577
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -593,11 +599,45 @@ describe('sse transport', () => {
|
|
|
593
599
|
expect(response.headers.get('Payment-Receipt')).toBeTruthy()
|
|
594
600
|
})
|
|
595
601
|
|
|
602
|
+
test('respondReceipt with bodyless POST management request does not deduct from channel', async () => {
|
|
603
|
+
const store = memoryStore()
|
|
604
|
+
await seedChannel(store, 10000000n)
|
|
605
|
+
const transport = sse({ store })
|
|
606
|
+
|
|
607
|
+
const response = transport.respondReceipt({
|
|
608
|
+
credential: Credential.from({
|
|
609
|
+
challenge: makeChallenge(),
|
|
610
|
+
payload: {
|
|
611
|
+
action: 'voucher',
|
|
612
|
+
channelId,
|
|
613
|
+
cumulativeAmount: '1000000',
|
|
614
|
+
signature: '0xdeadbeef',
|
|
615
|
+
},
|
|
616
|
+
}),
|
|
617
|
+
input: new Request('https://test.example.com/session', { method: 'POST' }),
|
|
618
|
+
receipt: makeReceipt(),
|
|
619
|
+
response: new Response(JSON.stringify({ ok: true }), {
|
|
620
|
+
headers: { 'Content-Type': 'application/json' },
|
|
621
|
+
}),
|
|
622
|
+
challengeId,
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
expect(await response.text()).toBe('{"ok":true}')
|
|
626
|
+
|
|
627
|
+
const channel = await store.getChannel(channelId)
|
|
628
|
+
expect(channel!.spent).toBe(0n)
|
|
629
|
+
expect(channel!.units).toBe(0)
|
|
630
|
+
})
|
|
631
|
+
|
|
596
632
|
test('respondReceipt with 204 content response still deducts from channel', async () => {
|
|
597
633
|
const store = memoryStore()
|
|
598
634
|
await seedChannel(store, 10000000n)
|
|
599
635
|
const transport = sse({ store })
|
|
600
|
-
const request =
|
|
636
|
+
const request = new Request('https://test.example.com/session', {
|
|
637
|
+
body: JSON.stringify({ prompt: 'hello' }),
|
|
638
|
+
headers: makeAuthorizedRequest().headers,
|
|
639
|
+
method: 'POST',
|
|
640
|
+
})
|
|
601
641
|
|
|
602
642
|
const contentResponse = new Response(null, { status: 204 })
|
|
603
643
|
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
|
}
|
|
@@ -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
|
-
}
|