mppx 0.4.9 → 0.4.11

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 (165) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/dist/cli/cli.d.ts.map +1 -1
  3. package/dist/cli/cli.js +155 -0
  4. package/dist/cli/cli.js.map +1 -1
  5. package/dist/discovery/Discovery.d.ts +146 -0
  6. package/dist/discovery/Discovery.d.ts.map +1 -0
  7. package/dist/discovery/Discovery.js +60 -0
  8. package/dist/discovery/Discovery.js.map +1 -0
  9. package/dist/discovery/OpenApi.d.ts +61 -0
  10. package/dist/discovery/OpenApi.d.ts.map +1 -0
  11. package/dist/discovery/OpenApi.js +139 -0
  12. package/dist/discovery/OpenApi.js.map +1 -0
  13. package/dist/discovery/Validate.d.ts +10 -0
  14. package/dist/discovery/Validate.d.ts.map +1 -0
  15. package/dist/discovery/Validate.js +63 -0
  16. package/dist/discovery/Validate.js.map +1 -0
  17. package/dist/discovery/index.d.ts +4 -0
  18. package/dist/discovery/index.d.ts.map +1 -0
  19. package/dist/discovery/index.js +4 -0
  20. package/dist/discovery/index.js.map +1 -0
  21. package/dist/middlewares/elysia.d.ts +52 -1
  22. package/dist/middlewares/elysia.d.ts.map +1 -1
  23. package/dist/middlewares/elysia.js +17 -0
  24. package/dist/middlewares/elysia.js.map +1 -1
  25. package/dist/middlewares/express.d.ts +13 -1
  26. package/dist/middlewares/express.d.ts.map +1 -1
  27. package/dist/middlewares/express.js +18 -0
  28. package/dist/middlewares/express.js.map +1 -1
  29. package/dist/middlewares/hono.d.ts +19 -1
  30. package/dist/middlewares/hono.d.ts.map +1 -1
  31. package/dist/middlewares/hono.js +51 -0
  32. package/dist/middlewares/hono.js.map +1 -1
  33. package/dist/middlewares/internal/mppx.d.ts +4 -2
  34. package/dist/middlewares/internal/mppx.d.ts.map +1 -1
  35. package/dist/middlewares/internal/mppx.js +10 -3
  36. package/dist/middlewares/internal/mppx.js.map +1 -1
  37. package/dist/middlewares/nextjs.d.ts +11 -0
  38. package/dist/middlewares/nextjs.d.ts.map +1 -1
  39. package/dist/middlewares/nextjs.js +15 -0
  40. package/dist/middlewares/nextjs.js.map +1 -1
  41. package/dist/proxy/Proxy.d.ts +6 -0
  42. package/dist/proxy/Proxy.d.ts.map +1 -1
  43. package/dist/proxy/Proxy.js +56 -80
  44. package/dist/proxy/Proxy.js.map +1 -1
  45. package/dist/proxy/Service.d.ts +16 -23
  46. package/dist/proxy/Service.d.ts.map +1 -1
  47. package/dist/proxy/Service.js +19 -83
  48. package/dist/proxy/Service.js.map +1 -1
  49. package/dist/proxy/internal/Route.js +1 -1
  50. package/dist/proxy/internal/Route.js.map +1 -1
  51. package/dist/proxy/services/anthropic.d.ts.map +1 -1
  52. package/dist/proxy/services/anthropic.js +5 -0
  53. package/dist/proxy/services/anthropic.js.map +1 -1
  54. package/dist/proxy/services/openai.d.ts.map +1 -1
  55. package/dist/proxy/services/openai.js +6 -3
  56. package/dist/proxy/services/openai.js.map +1 -1
  57. package/dist/proxy/services/stripe.d.ts.map +1 -1
  58. package/dist/proxy/services/stripe.js +6 -3
  59. package/dist/proxy/services/stripe.js.map +1 -1
  60. package/dist/stripe/internal/types.d.ts +3 -0
  61. package/dist/stripe/internal/types.d.ts.map +1 -1
  62. package/dist/stripe/server/Charge.d.ts.map +1 -1
  63. package/dist/stripe/server/Charge.js +9 -2
  64. package/dist/stripe/server/Charge.js.map +1 -1
  65. package/dist/tempo/server/Session.d.ts.map +1 -1
  66. package/dist/tempo/server/Session.js +25 -8
  67. package/dist/tempo/server/Session.js.map +1 -1
  68. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  69. package/dist/tempo/server/internal/transport.js +8 -0
  70. package/dist/tempo/server/internal/transport.js.map +1 -1
  71. package/dist/tempo/session/Chain.js +1 -1
  72. package/dist/tempo/session/Chain.js.map +1 -1
  73. package/package.json +6 -1
  74. package/src/BodyDigest.test.ts +1 -1
  75. package/src/Challenge.fuzz.test.ts +121 -0
  76. package/src/Challenge.test-d.ts +1 -1
  77. package/src/Challenge.test.ts +1 -1
  78. package/src/Credential.fuzz.test.ts +62 -0
  79. package/src/Credential.test.ts +1 -1
  80. package/src/Errors.test.ts +1 -1
  81. package/src/Expires.test.ts +1 -1
  82. package/src/Method.test.ts +1 -1
  83. package/src/PaymentRequest.test.ts +1 -1
  84. package/src/Receipt.test.ts +1 -1
  85. package/src/Store.test-d.ts +1 -1
  86. package/src/Store.test.ts +1 -1
  87. package/src/cli/cli.test.ts +212 -1
  88. package/src/cli/cli.ts +162 -0
  89. package/src/client/Mppx.test-d.ts +1 -1
  90. package/src/client/Mppx.test.ts +1 -1
  91. package/src/client/Transport.test.ts +1 -1
  92. package/src/client/internal/Fetch.browser.test.ts +1 -1
  93. package/src/client/internal/Fetch.test-d.ts +1 -1
  94. package/src/client/internal/Fetch.test.ts +2 -1
  95. package/src/discovery/Discovery.test.ts +152 -0
  96. package/src/discovery/Discovery.ts +72 -0
  97. package/src/discovery/OpenApi.test.ts +425 -0
  98. package/src/discovery/OpenApi.ts +224 -0
  99. package/src/discovery/Validate.test.ts +188 -0
  100. package/src/discovery/Validate.ts +76 -0
  101. package/src/discovery/index.ts +3 -0
  102. package/src/internal/constantTimeEqual.test.ts +1 -1
  103. package/src/mcp-sdk/client/McpClient.test-d.ts +1 -1
  104. package/src/mcp-sdk/client/McpClient.test.ts +1 -1
  105. package/src/mcp-sdk/server/Transport.test.ts +1 -1
  106. package/src/middlewares/elysia.test.ts +27 -2
  107. package/src/middlewares/elysia.ts +35 -1
  108. package/src/middlewares/express.test.ts +35 -7
  109. package/src/middlewares/express.ts +34 -0
  110. package/src/middlewares/hono.test.ts +28 -6
  111. package/src/middlewares/hono.ts +73 -1
  112. package/src/middlewares/internal/mppx.test.ts +1 -1
  113. package/src/middlewares/internal/mppx.ts +14 -6
  114. package/src/middlewares/nextjs.test.ts +31 -6
  115. package/src/middlewares/nextjs.ts +28 -0
  116. package/src/proxy/Proxy.test.ts +54 -270
  117. package/src/proxy/Proxy.ts +71 -93
  118. package/src/proxy/Service.test.ts +23 -1
  119. package/src/proxy/Service.ts +40 -86
  120. package/src/proxy/internal/Headers.test.ts +1 -1
  121. package/src/proxy/internal/Route.test.ts +9 -1
  122. package/src/proxy/internal/Route.ts +1 -1
  123. package/src/proxy/services/anthropic.test.ts +132 -0
  124. package/src/proxy/services/anthropic.ts +5 -0
  125. package/src/proxy/services/openai.test.ts +1 -1
  126. package/src/proxy/services/openai.ts +6 -4
  127. package/src/proxy/services/stripe.test.ts +132 -0
  128. package/src/proxy/services/stripe.ts +6 -4
  129. package/src/server/Mppx.test-d.ts +1 -1
  130. package/src/server/Mppx.test.ts +2 -1
  131. package/src/server/NodeListener.test.ts +1 -1
  132. package/src/server/Request.test.ts +1 -1
  133. package/src/server/Response.test.ts +1 -1
  134. package/src/server/Transport.test.ts +1 -1
  135. package/src/stripe/Charge.integration.test.ts +1 -1
  136. package/src/stripe/Methods.test.ts +1 -1
  137. package/src/stripe/client/Charge.test.ts +1 -1
  138. package/src/stripe/internal/types.ts +5 -1
  139. package/src/stripe/server/Charge.test.ts +53 -2
  140. package/src/stripe/server/Charge.ts +12 -4
  141. package/src/tempo/Attribution.test.ts +1 -1
  142. package/src/tempo/Methods.test.ts +1 -1
  143. package/src/tempo/client/ChannelOps.test.ts +6 -3
  144. package/src/tempo/client/Session.test.ts +5 -2
  145. package/src/tempo/client/SessionManager.test.ts +1 -1
  146. package/src/tempo/internal/auto-swap.test.ts +1 -1
  147. package/src/tempo/internal/defaults.test.ts +1 -1
  148. package/src/tempo/internal/fee-payer.test.ts +1 -1
  149. package/src/tempo/server/Charge.test.ts +1 -1
  150. package/src/tempo/server/Session.test.ts +116 -37
  151. package/src/tempo/server/Session.ts +32 -11
  152. package/src/tempo/server/Sse.test.ts +1 -1
  153. package/src/tempo/server/internal/transport.test.ts +24 -1
  154. package/src/tempo/server/internal/transport.ts +11 -0
  155. package/src/tempo/session/Chain.test.ts +5 -2
  156. package/src/tempo/session/Chain.ts +1 -1
  157. package/src/tempo/session/Channel.test.ts +1 -1
  158. package/src/tempo/session/ChannelStore.test.ts +1 -1
  159. package/src/tempo/session/Receipt.test.ts +1 -1
  160. package/src/tempo/session/Sse.fuzz.test.ts +138 -0
  161. package/src/tempo/session/Sse.test.ts +1 -1
  162. package/src/tempo/session/Voucher.test.ts +1 -1
  163. package/src/viem/Account.test.ts +1 -1
  164. package/src/viem/Client.test.ts +1 -1
  165. package/src/zod.test.ts +147 -0
@@ -1,7 +1,7 @@
1
1
  import { Receipt } from 'mppx'
2
2
  import { Mppx as Mppx_client, tempo as tempo_client } from 'mppx/client'
3
3
  import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
4
- import { afterEach, describe, expect, test } from 'vitest'
4
+ import { afterEach, describe, expect, test } from 'vp/test'
5
5
  import * as Http from '~test/Http.js'
6
6
  import { accounts, asset, client } from '~test/tempo/viem.js'
7
7
 
@@ -65,11 +65,19 @@ function createUpstream(handler: (req: Request) => Response | Promise<Response>)
65
65
  }
66
66
 
67
67
  describe('create', () => {
68
- test('behavior: GET /discover/all returns service discovery JSON', async () => {
68
+ test('behavior: GET /openapi.json returns discovery JSON', async () => {
69
69
  const proxy = ApiProxy.create({
70
+ categories: ['gateway'],
71
+ docs: {
72
+ apiReference: 'https://gateway.example.com/reference',
73
+ homepage: 'https://gateway.example.com',
74
+ },
75
+ title: 'My AI Gateway',
76
+ version: '2.0.0',
70
77
  services: [
71
78
  Service.from('api', {
72
79
  baseUrl: 'https://api.example.com',
80
+ categories: ['compute'],
73
81
  routes: {
74
82
  'GET /v1/models': true,
75
83
  'POST /v1/generate': mppx_server.charge({ amount: '1', description: 'Generate text' }),
@@ -84,73 +92,40 @@ describe('create', () => {
84
92
  })
85
93
  proxyServer = await Http.createServer(proxy.listener)
86
94
 
87
- const res = await fetch(`${proxyServer.url}/discover/all`)
95
+ const res = await fetch(`${proxyServer.url}/openapi.json`)
88
96
  expect(res.status).toBe(200)
89
- expect(await res.json()).toMatchInlineSnapshot(`
90
- [
91
- {
92
- "id": "api",
93
- "routes": [
94
- {
95
- "method": "GET",
96
- "path": "/api/v1/models",
97
- "pattern": "GET /api/v1/models",
98
- "payment": null,
99
- },
100
- {
101
- "method": "POST",
102
- "path": "/api/v1/generate",
103
- "pattern": "POST /api/v1/generate",
104
- "payment": {
105
- "amount": "1000000",
106
- "currency": "0x20c0000000000000000000000000000000000001",
107
- "decimals": 6,
108
- "description": "Generate text",
109
- "intent": "charge",
110
- "method": "tempo",
111
- "recipient": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
112
- },
113
- },
114
- {
115
- "method": "POST",
116
- "path": "/api/v1/stream",
117
- "pattern": "POST /api/v1/stream",
118
- "payment": {
119
- "amount": "1000000",
120
- "currency": "0x20c0000000000000000000000000000000000001",
121
- "decimals": 6,
122
- "description": "Stream text",
123
- "intent": "session",
124
- "method": "tempo",
125
- "recipient": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
126
- "unitType": "token",
127
- },
128
- },
129
- ],
130
- },
131
- ]
132
- `)
133
- })
134
-
135
- test('behavior: GET /discover returns JSON by default', async () => {
136
- const proxy = ApiProxy.create({
137
- services: [
138
- Service.from('api', {
139
- baseUrl: 'https://api.example.com',
140
- routes: {
141
- 'GET /v1/models': true,
142
- },
143
- }),
144
- ],
97
+ expect(res.headers.get('cache-control')).toBe('public, max-age=300')
98
+ const body = (await res.json()) as Record<string, any>
99
+ expect(body.openapi).toBe('3.1.0')
100
+ expect(body.info).toEqual({ title: 'My AI Gateway', version: '2.0.0' })
101
+ expect(body['x-service-info']).toEqual({
102
+ categories: ['gateway'],
103
+ docs: {
104
+ apiReference: 'https://gateway.example.com/reference',
105
+ homepage: 'https://gateway.example.com',
106
+ llms: '/llms.txt',
107
+ },
108
+ })
109
+ expect(body.paths['/api/v1/models'].get.responses['200']).toEqual({
110
+ description: 'Successful response',
111
+ })
112
+ expect(body.paths['/api/v1/generate'].post['x-payment-info']).toMatchObject({
113
+ amount: '1000000',
114
+ currency: asset,
115
+ description: 'Generate text',
116
+ intent: 'charge',
117
+ method: 'tempo',
118
+ })
119
+ expect(body.paths['/api/v1/stream'].post['x-payment-info']).toMatchObject({
120
+ amount: '1000000',
121
+ currency: asset,
122
+ description: 'Stream text',
123
+ intent: 'session',
124
+ method: 'tempo',
145
125
  })
146
- proxyServer = await Http.createServer(proxy.listener)
147
-
148
- const res = await fetch(`${proxyServer.url}/discover`)
149
- expect(res.status).toBe(200)
150
- expect(res.headers.get('content-type')).toMatchInlineSnapshot(`"application/json"`)
151
126
  })
152
127
 
153
- test('behavior: GET /discover returns llms.txt for markdown clients', async () => {
128
+ test('behavior: GET /llms.txt returns text docs linked to OpenAPI discovery', async () => {
154
129
  const proxy = ApiProxy.create({
155
130
  title: 'My AI Gateway',
156
131
  description: 'A paid proxy for LLM and AI services.',
@@ -186,9 +161,7 @@ describe('create', () => {
186
161
  })
187
162
  proxyServer = await Http.createServer(proxy.listener)
188
163
 
189
- const res = await fetch(`${proxyServer.url}/discover`, {
190
- headers: { Accept: 'text/plain' },
191
- })
164
+ const res = await fetch(`${proxyServer.url}/llms.txt`)
192
165
  expect(res.status).toBe(200)
193
166
  expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8')
194
167
  expect(await res.text()).toMatchInlineSnapshot(`
@@ -198,20 +171,20 @@ describe('create', () => {
198
171
 
199
172
  ## Services
200
173
 
201
- - [OpenAI](/discover/openai.md): Chat completions, embeddings, image generation, and audio transcription.
202
- - [Anthropic](/discover/anthropic.md): Claude language models for messages and completions.
174
+ - OpenAI: Chat completions, embeddings, image generation, and audio transcription.
175
+ - Anthropic: Claude language models for messages and completions.
203
176
 
204
- [See all service definitions](/discover/all.md)"
177
+ [OpenAPI discovery](/openapi.json)"
205
178
  `)
206
179
  })
207
180
 
208
- test('behavior: GET /discover/:id returns single service', async () => {
181
+ test('behavior: GET /openapi.json respects basePath', async () => {
209
182
  const proxy = ApiProxy.create({
183
+ basePath: '/proxy',
210
184
  services: [
211
185
  Service.from('api', {
212
186
  baseUrl: 'https://api.example.com',
213
187
  routes: {
214
- 'GET /v1/models': true,
215
188
  'POST /v1/generate': mppx_server.charge({ amount: '1', description: 'Generate text' }),
216
189
  },
217
190
  }),
@@ -219,205 +192,16 @@ describe('create', () => {
219
192
  })
220
193
  proxyServer = await Http.createServer(proxy.listener)
221
194
 
222
- const res = await fetch(`${proxyServer.url}/discover/api`)
223
- expect(res.status).toBe(200)
224
- expect(await res.json()).toMatchInlineSnapshot(`
225
- {
226
- "id": "api",
227
- "routes": [
228
- {
229
- "method": "GET",
230
- "path": "/api/v1/models",
231
- "pattern": "GET /api/v1/models",
232
- "payment": null,
233
- },
234
- {
235
- "method": "POST",
236
- "path": "/api/v1/generate",
237
- "pattern": "POST /api/v1/generate",
238
- "payment": {
239
- "amount": "1000000",
240
- "currency": "0x20c0000000000000000000000000000000000001",
241
- "decimals": 6,
242
- "description": "Generate text",
243
- "intent": "charge",
244
- "method": "tempo",
245
- "recipient": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
246
- },
247
- },
248
- ],
249
- }
250
- `)
251
- })
252
-
253
- test('behavior: GET /discover/all.md returns full markdown with routes', async () => {
254
- const proxy = ApiProxy.create({
255
- services: [
256
- openai({
257
- apiKey: 'sk-test',
258
- routes: {
259
- 'POST /v1/chat/completions': mppx_server.charge({
260
- amount: '0.05',
261
- description: 'Chat completion',
262
- }),
263
- 'GET /v1/models': true,
264
- },
265
- }),
266
- anthropic({
267
- apiKey: 'sk-ant-test',
268
- routes: {
269
- 'POST /v1/messages': mppx_server.charge({
270
- amount: '0.03',
271
- description: 'Send message',
272
- }),
273
- },
274
- }),
275
- ],
276
- })
277
- proxyServer = await Http.createServer(proxy.listener)
278
-
279
- const res = await fetch(`${proxyServer.url}/discover/all.md`)
195
+ const res = await fetch(`${proxyServer.url}/proxy/openapi.json`)
280
196
  expect(res.status).toBe(200)
281
- expect(res.headers.get('content-type')).toBe('text/markdown; charset=utf-8')
282
- expect(await res.text()).toMatchInlineSnapshot(`
283
- "# Services
284
-
285
- ## [OpenAI](/discover/openai.md)
286
-
287
- Chat completions, embeddings, image generation, and audio transcription.
288
-
289
- ### Routes
290
-
291
- - \`POST /openai/v1/chat/completions\`: Chat completion
292
- - Type: charge
293
- - Price: 0.05 (50000 units, 6 decimals)
294
- - Currency: 0x20c0000000000000000000000000000000000001
295
- - Docs: https://context7.com/websites/platform_openai/llms.txt?topic=POST%20%2Fv1%2Fchat%2Fcompletions
296
-
297
- - \`GET /openai/v1/models\`
298
- - Type: free
299
- - Docs: https://context7.com/websites/platform_openai/llms.txt?topic=GET%20%2Fv1%2Fmodels
300
-
301
- ## [Anthropic](/discover/anthropic.md)
302
-
303
- Claude language models for messages and completions.
304
-
305
- ### Routes
306
-
307
- - \`POST /anthropic/v1/messages\`: Send message
308
- - Type: charge
309
- - Price: 0.03 (30000 units, 6 decimals)
310
- - Currency: 0x20c0000000000000000000000000000000000001
311
- "
312
- `)
313
- })
314
-
315
- test('behavior: GET /discover/:id.md returns markdown', async () => {
316
- const proxy = ApiProxy.create({
317
- services: [
318
- openai({
319
- apiKey: 'sk-test',
320
- routes: {
321
- 'POST /v1/chat/completions': mppx_server.charge({
322
- amount: '0.05',
323
- description: 'Chat completion',
324
- }),
325
- 'GET /v1/models': true,
326
- },
327
- }),
328
- anthropic({
329
- apiKey: 'sk-ant-test',
330
- routes: {
331
- 'POST /v1/messages': mppx_server.charge({ amount: '0.03' }),
332
- },
333
- }),
334
- ],
335
- })
336
- proxyServer = await Http.createServer(proxy.listener)
337
-
338
- const res = await fetch(`${proxyServer.url}/discover/openai.md`)
339
- expect(res.status).toBe(200)
340
- expect(res.headers.get('content-type')).toBe('text/markdown; charset=utf-8')
341
- expect(await res.text()).toMatchInlineSnapshot(`
342
- "# OpenAI
343
-
344
- > Documentation: https://context7.com/websites/platform_openai/llms.txt
345
-
346
- Chat completions, embeddings, image generation, and audio transcription.
347
-
348
- ## Routes
349
-
350
- - \`POST /openai/v1/chat/completions\`: Chat completion
351
- - Type: charge
352
- - Price: 0.05 (50000 units, 6 decimals)
353
- - Currency: 0x20c0000000000000000000000000000000000001
354
- - Docs: https://context7.com/websites/platform_openai/llms.txt?topic=POST%20%2Fv1%2Fchat%2Fcompletions
355
-
356
- - \`GET /openai/v1/models\`
357
- - Type: free
358
- - Docs: https://context7.com/websites/platform_openai/llms.txt?topic=GET%20%2Fv1%2Fmodels
359
- "
360
- `)
361
- })
362
-
363
- test('behavior: GET /discover/:id with Accept: text/markdown returns markdown', async () => {
364
- const proxy = ApiProxy.create({
365
- services: [
366
- openai({
367
- apiKey: 'sk-test',
368
- routes: { 'GET /v1/models': true },
369
- }),
370
- anthropic({
371
- apiKey: 'sk-ant-test',
372
- routes: {
373
- 'POST /v1/messages': mppx_server.charge({ amount: '0.03' }),
374
- },
375
- }),
376
- ],
377
- })
378
- proxyServer = await Http.createServer(proxy.listener)
379
-
380
- const res = await fetch(`${proxyServer.url}/discover/anthropic`, {
381
- headers: { Accept: 'text/markdown' },
382
- })
383
- expect(res.status).toBe(200)
384
- expect(res.headers.get('content-type')).toBe('text/markdown; charset=utf-8')
385
- })
386
-
387
- test('behavior: GET /discover/:id without Accept returns JSON', async () => {
388
- const proxy = ApiProxy.create({
389
- services: [
390
- openai({
391
- apiKey: 'sk-test',
392
- routes: { 'GET /v1/models': true },
393
- }),
394
- anthropic({
395
- apiKey: 'sk-ant-test',
396
- routes: {
397
- 'POST /v1/messages': mppx_server.charge({ amount: '0.03' }),
398
- },
399
- }),
400
- ],
197
+ const body = (await res.json()) as Record<string, any>
198
+ expect(body.paths['/proxy/api/v1/generate'].post['x-payment-info']).toMatchObject({
199
+ amount: '1000000',
200
+ currency: asset,
201
+ description: 'Generate text',
202
+ intent: 'charge',
203
+ method: 'tempo',
401
204
  })
402
- proxyServer = await Http.createServer(proxy.listener)
403
-
404
- const res = await fetch(`${proxyServer.url}/discover/openai`)
405
- expect(res.status).toBe(200)
406
- expect(res.headers.get('content-type')).toMatchInlineSnapshot(`"application/json"`)
407
- })
408
-
409
- test('behavior: GET /discover/:id.md returns 404 for unknown', async () => {
410
- const proxy = ApiProxy.create({ services: [] })
411
- proxyServer = await Http.createServer(proxy.listener)
412
- const res = await fetch(`${proxyServer.url}/discover/unknown.md`)
413
- expect(res.status).toBe(404)
414
- })
415
-
416
- test('behavior: GET /discover/:id returns 404 for unknown', async () => {
417
- const proxy = ApiProxy.create({ services: [] })
418
- proxyServer = await Http.createServer(proxy.listener)
419
- const res = await fetch(`${proxyServer.url}/discover/unknown`)
420
- expect(res.status).toBe(404)
421
205
  })
422
206
 
423
207
  test('behavior: returns 404 for unknown service', async () => {
@@ -2,6 +2,7 @@ import type * as http from 'node:http'
2
2
 
3
3
  import { createFetchProxy } from '@remix-run/fetch-proxy'
4
4
 
5
+ import { generateProxy } from '../discovery/OpenApi.js'
5
6
  import * as Request from '../server/Request.js'
6
7
  import * as Headers from './internal/Headers.js'
7
8
  import * as Route from './internal/Route.js'
@@ -55,6 +56,24 @@ export function create(config: create.Config): Proxy {
55
56
  }),
56
57
  )
57
58
 
59
+ // Pre-generate static discovery responses once at startup.
60
+ const openApiJson = JSON.stringify(
61
+ generateProxy({
62
+ basePath: config.basePath,
63
+ info: {
64
+ title: config.title ?? 'API Proxy',
65
+ version: config.version ?? '1.0.0',
66
+ },
67
+ routes: buildDiscoveryRoutes(config.services),
68
+ serviceInfo: buildServiceInfo(config),
69
+ }),
70
+ )
71
+ const llmsTxt = Service.toLlmsTxt(config.services, {
72
+ title: config.title,
73
+ description: config.description,
74
+ openApiPath: withBasePath(config.basePath, '/openapi.json'),
75
+ })
76
+
58
77
  async function handle(request: globalThis.Request): Promise<Response> {
59
78
  const url = new URL(request.url)
60
79
 
@@ -62,68 +81,22 @@ export function create(config: create.Config): Proxy {
62
81
 
63
82
  if (!pathname) return new Response('Not Found', { status: 404 })
64
83
 
65
- if (request.method === 'GET' && pathname === '/llms.txt')
66
- return new Response(
67
- Service.toLlmsTxt(config.services, {
68
- title: config.title,
69
- description: config.description,
70
- }),
71
- { headers: { 'Content-Type': 'text/plain; charset=utf-8' } },
72
- )
73
-
74
- if (request.method === 'GET' && pathname === '/discover.md')
75
- return new Response(
76
- Service.toLlmsTxt(config.services, {
77
- title: config.title,
78
- description: config.description,
79
- }),
80
- { headers: { 'Content-Type': 'text/plain; charset=utf-8' } },
81
- )
82
-
83
- if (request.method === 'GET' && (pathname === '/discover' || pathname === '/discover/')) {
84
- if (wantsMarkdown(request))
85
- return new Response(
86
- Service.toLlmsTxt(config.services, {
87
- title: config.title,
88
- description: config.description,
89
- }),
90
- { headers: { 'Content-Type': 'text/plain; charset=utf-8' } },
91
- )
92
- return Response.json(config.services.map(Service.serialize))
93
- }
94
-
95
84
  if (
96
85
  request.method === 'GET' &&
97
- (pathname === '/discover/all' || pathname === '/discover/all/')
86
+ (pathname === '/openapi.json' || pathname === '/openapi.json/')
98
87
  ) {
99
- if (wantsMarkdown(request))
100
- return new Response(Service.toServicesMarkdown(config.services), {
101
- headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
102
- })
103
- return Response.json(config.services.map(Service.serialize))
104
- }
105
-
106
- if (request.method === 'GET' && pathname === '/discover/all.md')
107
- return new Response(Service.toServicesMarkdown(config.services), {
108
- headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
88
+ return new Response(openApiJson, {
89
+ headers: {
90
+ 'Cache-Control': 'public, max-age=300',
91
+ 'Content-Type': 'application/json',
92
+ },
109
93
  })
110
-
111
- {
112
- // List service
113
- const match =
114
- pathname.match(/^\/discover\/([^/]+)\.md$/) ?? pathname.match(/^\/discover\/([^/]+)\/?$/)
115
- if (request.method === 'GET' && match) {
116
- const service = config.services.find((s) => s.id === match[1])
117
- if (!service) return new Response('Not Found', { status: 404 })
118
- const wantsText = pathname.endsWith('.md') || wantsMarkdown(request)
119
- if (wantsText)
120
- return new Response(Service.toMarkdown(service), {
121
- headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
122
- })
123
- return Response.json(Service.serialize(service))
124
- }
125
94
  }
126
95
 
96
+ if (request.method === 'GET' && pathname === '/llms.txt')
97
+ return new Response(llmsTxt, {
98
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
99
+ })
127
100
  const parsed = Route.parse(pathname)
128
101
  if (!parsed) return new Response('Not Found', { status: 404 })
129
102
 
@@ -177,14 +150,20 @@ export declare namespace create {
177
150
  export type Config = {
178
151
  /** Base path prefix to strip before routing (e.g. `'/api/proxy'`). */
179
152
  basePath?: string | undefined
153
+ /** Free-form categories for root discovery metadata. */
154
+ categories?: string[] | undefined
180
155
  /** Short description of the proxy shown in `llms.txt`. */
181
156
  description?: string | undefined
157
+ /** Structured documentation links for root discovery metadata. */
158
+ docs?: Service.Docs | undefined
182
159
  /** Custom `fetch` implementation. Defaults to `globalThis.fetch`. */
183
160
  fetch?: typeof globalThis.fetch | undefined
184
161
  /** Services to proxy. Each service is mounted at `/{serviceId}/`. */
185
162
  services: Service.Service[]
186
163
  /** Human-readable title for the proxy shown in `llms.txt`. */
187
164
  title?: string | undefined
165
+ /** Version to include in the generated OpenAPI document. */
166
+ version?: string | undefined
188
167
  }
189
168
  }
190
169
 
@@ -230,41 +209,40 @@ async function proxyUpstream(options: proxyUpstream.Options): Promise<Response>
230
209
  return upstreamRes
231
210
  }
232
211
 
233
- const aiUserAgents = [
234
- 'GPTBot',
235
- 'OAI-SearchBot',
236
- 'ChatGPT-User',
237
- 'anthropic-ai',
238
- 'ClaudeBot',
239
- 'claude-web',
240
- 'PerplexityBot',
241
- 'Perplexity-User',
242
- 'Google-Extended',
243
- 'Googlebot',
244
- 'Bingbot',
245
- 'Amazonbot',
246
- 'Applebot',
247
- 'Applebot-Extended',
248
- 'FacebookBot',
249
- 'meta-externalagent',
250
- 'Bytespider',
251
- 'DuckAssistBot',
252
- 'cohere-ai',
253
- 'AI2Bot',
254
- 'CCBot',
255
- 'Diffbot',
256
- 'YouBot',
257
- 'MistralAI-User',
258
- 'GoogleAgent-Mariner',
259
- ]
260
-
261
- const terminalUserAgents = ['curl', 'Wget', 'HTTPie', 'httpie-go', 'mppx', 'presto', 'xh']
262
-
263
- function wantsMarkdown(request: globalThis.Request): boolean {
264
- const accept = request.headers.get('accept')
265
- if (accept && (accept.includes('text/markdown') || accept.includes('text/plain'))) return true
266
- const ua = request.headers.get('user-agent') ?? ''
267
- if (aiUserAgents.some((agent) => ua.includes(agent))) return true
268
- if (terminalUserAgents.some((agent) => ua.includes(agent))) return true
269
- return false
212
+ function buildDiscoveryRoutes(services: Service.Service[]) {
213
+ return services.flatMap((service) =>
214
+ Object.entries(service.routes).map(([pattern, endpoint]) => {
215
+ const tokens = pattern.trim().split(/\s+/)
216
+ const hasMethod = tokens.length >= 2
217
+ const path = hasMethod ? tokens.slice(1).join(' ') : tokens[0]
218
+ return {
219
+ method: hasMethod ? tokens[0]! : 'GET',
220
+ path: `/${service.id}${path}`,
221
+ payment: endpoint ? Service.paymentOf(endpoint) : null,
222
+ }
223
+ }),
224
+ )
225
+ }
226
+
227
+ function buildServiceInfo(config: create.Config): { categories?: string[]; docs?: Service.Docs } {
228
+ const categories =
229
+ config.categories ??
230
+ Array.from(new Set(config.services.flatMap((service) => service.categories ?? [])))
231
+
232
+ const docs = {
233
+ ...(config.docs ?? {}),
234
+ llms: config.docs?.llms ?? withBasePath(config.basePath, '/llms.txt'),
235
+ }
236
+
237
+ return {
238
+ ...(categories.length > 0 ? { categories } : {}),
239
+ docs,
240
+ }
241
+ }
242
+
243
+ function withBasePath(basePath: string | undefined, path: string) {
244
+ if (!basePath) return path
245
+ const normalized = basePath.startsWith('/') ? basePath : `/${basePath}`
246
+ const trimmed = normalized.endsWith('/') ? normalized.slice(0, -1) : normalized
247
+ return `${trimmed}${path}`
270
248
  }
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from 'vitest'
1
+ import { describe, expect, test } from 'vp/test'
2
2
 
3
3
  import * as Service from './Service.js'
4
4
 
@@ -102,6 +102,28 @@ describe('custom', () => {
102
102
  })
103
103
  })
104
104
 
105
+ describe('paymentOf', () => {
106
+ test('behavior: returns null for free passthrough endpoint', () => {
107
+ expect(Service.paymentOf(true)).toBeNull()
108
+ })
109
+
110
+ test('behavior: returns null for paid handler without _internal metadata', () => {
111
+ const handler: Service.IntentHandler = async () => ({
112
+ status: 200 as const,
113
+ withReceipt: <T>(r: T) => r,
114
+ })
115
+ expect(Service.paymentOf(handler)).toBeNull()
116
+ })
117
+
118
+ test('behavior: returns null for paid endpoint object without _internal metadata', () => {
119
+ const handler: Service.IntentHandler = async () => ({
120
+ status: 200 as const,
121
+ withReceipt: <T>(r: T) => r,
122
+ })
123
+ expect(Service.paymentOf({ pay: handler, options: {} })).toBeNull()
124
+ })
125
+ })
126
+
105
127
  describe('getOptions', () => {
106
128
  test('behavior: returns options from endpoint object', () => {
107
129
  const handler: Service.IntentHandler = async () => ({