mppx 0.4.9 → 0.4.10
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 +17 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +155 -0
- package/dist/cli/cli.js.map +1 -1
- package/dist/discovery/Discovery.d.ts +146 -0
- package/dist/discovery/Discovery.d.ts.map +1 -0
- package/dist/discovery/Discovery.js +60 -0
- package/dist/discovery/Discovery.js.map +1 -0
- package/dist/discovery/OpenApi.d.ts +61 -0
- package/dist/discovery/OpenApi.d.ts.map +1 -0
- package/dist/discovery/OpenApi.js +139 -0
- package/dist/discovery/OpenApi.js.map +1 -0
- package/dist/discovery/Validate.d.ts +10 -0
- package/dist/discovery/Validate.d.ts.map +1 -0
- package/dist/discovery/Validate.js +63 -0
- package/dist/discovery/Validate.js.map +1 -0
- package/dist/discovery/index.d.ts +4 -0
- package/dist/discovery/index.d.ts.map +1 -0
- package/dist/discovery/index.js +4 -0
- package/dist/discovery/index.js.map +1 -0
- package/dist/middlewares/elysia.d.ts +52 -1
- package/dist/middlewares/elysia.d.ts.map +1 -1
- package/dist/middlewares/elysia.js +17 -0
- package/dist/middlewares/elysia.js.map +1 -1
- package/dist/middlewares/express.d.ts +13 -1
- package/dist/middlewares/express.d.ts.map +1 -1
- package/dist/middlewares/express.js +18 -0
- package/dist/middlewares/express.js.map +1 -1
- package/dist/middlewares/hono.d.ts +19 -1
- package/dist/middlewares/hono.d.ts.map +1 -1
- package/dist/middlewares/hono.js +51 -0
- package/dist/middlewares/hono.js.map +1 -1
- package/dist/middlewares/internal/mppx.d.ts +4 -2
- package/dist/middlewares/internal/mppx.d.ts.map +1 -1
- package/dist/middlewares/internal/mppx.js +10 -3
- package/dist/middlewares/internal/mppx.js.map +1 -1
- package/dist/middlewares/nextjs.d.ts +11 -0
- package/dist/middlewares/nextjs.d.ts.map +1 -1
- package/dist/middlewares/nextjs.js +15 -0
- package/dist/middlewares/nextjs.js.map +1 -1
- package/dist/proxy/Proxy.d.ts +6 -0
- package/dist/proxy/Proxy.d.ts.map +1 -1
- package/dist/proxy/Proxy.js +56 -80
- package/dist/proxy/Proxy.js.map +1 -1
- package/dist/proxy/Service.d.ts +16 -23
- package/dist/proxy/Service.d.ts.map +1 -1
- package/dist/proxy/Service.js +19 -83
- package/dist/proxy/Service.js.map +1 -1
- package/dist/proxy/internal/Route.js +1 -1
- package/dist/proxy/internal/Route.js.map +1 -1
- package/dist/proxy/services/anthropic.d.ts.map +1 -1
- package/dist/proxy/services/anthropic.js +5 -0
- package/dist/proxy/services/anthropic.js.map +1 -1
- package/dist/proxy/services/openai.d.ts.map +1 -1
- package/dist/proxy/services/openai.js +6 -3
- package/dist/proxy/services/openai.js.map +1 -1
- package/dist/proxy/services/stripe.d.ts.map +1 -1
- package/dist/proxy/services/stripe.js +6 -3
- package/dist/proxy/services/stripe.js.map +1 -1
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +18 -5
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/server/internal/transport.d.ts.map +1 -1
- package/dist/tempo/server/internal/transport.js +8 -0
- package/dist/tempo/server/internal/transport.js.map +1 -1
- package/dist/tempo/session/Chain.js +1 -1
- package/dist/tempo/session/Chain.js.map +1 -1
- package/package.json +6 -1
- package/src/BodyDigest.test.ts +1 -1
- package/src/Challenge.fuzz.test.ts +121 -0
- package/src/Challenge.test-d.ts +1 -1
- package/src/Challenge.test.ts +1 -1
- package/src/Credential.fuzz.test.ts +62 -0
- package/src/Credential.test.ts +1 -1
- package/src/Errors.test.ts +1 -1
- package/src/Expires.test.ts +1 -1
- package/src/Method.test.ts +1 -1
- package/src/PaymentRequest.test.ts +1 -1
- package/src/Receipt.test.ts +1 -1
- package/src/Store.test-d.ts +1 -1
- package/src/Store.test.ts +1 -1
- package/src/cli/cli.test.ts +212 -1
- package/src/cli/cli.ts +162 -0
- package/src/client/Mppx.test-d.ts +1 -1
- package/src/client/Mppx.test.ts +1 -1
- package/src/client/Transport.test.ts +1 -1
- package/src/client/internal/Fetch.browser.test.ts +1 -1
- package/src/client/internal/Fetch.test-d.ts +1 -1
- package/src/client/internal/Fetch.test.ts +2 -1
- package/src/discovery/Discovery.test.ts +152 -0
- package/src/discovery/Discovery.ts +72 -0
- package/src/discovery/OpenApi.test.ts +425 -0
- package/src/discovery/OpenApi.ts +224 -0
- package/src/discovery/Validate.test.ts +188 -0
- package/src/discovery/Validate.ts +76 -0
- package/src/discovery/index.ts +3 -0
- package/src/internal/constantTimeEqual.test.ts +1 -1
- package/src/mcp-sdk/client/McpClient.test-d.ts +1 -1
- package/src/mcp-sdk/client/McpClient.test.ts +1 -1
- package/src/mcp-sdk/server/Transport.test.ts +1 -1
- package/src/middlewares/elysia.test.ts +27 -2
- package/src/middlewares/elysia.ts +35 -1
- package/src/middlewares/express.test.ts +35 -7
- package/src/middlewares/express.ts +34 -0
- package/src/middlewares/hono.test.ts +28 -6
- package/src/middlewares/hono.ts +73 -1
- package/src/middlewares/internal/mppx.test.ts +1 -1
- package/src/middlewares/internal/mppx.ts +14 -6
- package/src/middlewares/nextjs.test.ts +31 -6
- package/src/middlewares/nextjs.ts +28 -0
- package/src/proxy/Proxy.test.ts +54 -270
- package/src/proxy/Proxy.ts +71 -93
- package/src/proxy/Service.test.ts +23 -1
- package/src/proxy/Service.ts +40 -86
- package/src/proxy/internal/Headers.test.ts +1 -1
- package/src/proxy/internal/Route.test.ts +9 -1
- package/src/proxy/internal/Route.ts +1 -1
- package/src/proxy/services/anthropic.test.ts +132 -0
- package/src/proxy/services/anthropic.ts +5 -0
- package/src/proxy/services/openai.test.ts +1 -1
- package/src/proxy/services/openai.ts +6 -4
- package/src/proxy/services/stripe.test.ts +132 -0
- package/src/proxy/services/stripe.ts +6 -4
- package/src/server/Mppx.test-d.ts +1 -1
- package/src/server/Mppx.test.ts +2 -1
- package/src/server/NodeListener.test.ts +1 -1
- package/src/server/Request.test.ts +1 -1
- package/src/server/Response.test.ts +1 -1
- package/src/server/Transport.test.ts +1 -1
- package/src/stripe/Charge.integration.test.ts +1 -1
- package/src/stripe/Methods.test.ts +1 -1
- package/src/stripe/client/Charge.test.ts +1 -1
- package/src/stripe/server/Charge.test.ts +1 -1
- package/src/tempo/Attribution.test.ts +1 -1
- package/src/tempo/Methods.test.ts +1 -1
- package/src/tempo/client/ChannelOps.test.ts +6 -3
- package/src/tempo/client/Session.test.ts +5 -2
- package/src/tempo/client/SessionManager.test.ts +1 -1
- package/src/tempo/internal/auto-swap.test.ts +1 -1
- package/src/tempo/internal/defaults.test.ts +1 -1
- package/src/tempo/internal/fee-payer.test.ts +1 -1
- package/src/tempo/server/Charge.test.ts +1 -1
- package/src/tempo/server/Session.test.ts +87 -37
- package/src/tempo/server/Session.ts +25 -8
- package/src/tempo/server/Sse.test.ts +1 -1
- package/src/tempo/server/internal/transport.test.ts +24 -1
- package/src/tempo/server/internal/transport.ts +11 -0
- package/src/tempo/session/Chain.test.ts +5 -2
- package/src/tempo/session/Chain.ts +1 -1
- package/src/tempo/session/Channel.test.ts +1 -1
- package/src/tempo/session/ChannelStore.test.ts +1 -1
- package/src/tempo/session/Receipt.test.ts +1 -1
- package/src/tempo/session/Sse.fuzz.test.ts +138 -0
- package/src/tempo/session/Sse.test.ts +1 -1
- package/src/tempo/session/Voucher.test.ts +1 -1
- package/src/viem/Account.test.ts +1 -1
- package/src/viem/Client.test.ts +1 -1
- package/src/zod.test.ts +147 -0
package/src/proxy/Proxy.ts
CHANGED
|
@@ -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 === '/
|
|
86
|
+
(pathname === '/openapi.json' || pathname === '/openapi.json/')
|
|
98
87
|
) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
if (
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
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 '
|
|
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 () => ({
|
package/src/proxy/Service.ts
CHANGED
|
@@ -4,12 +4,14 @@ import { Value } from 'ox'
|
|
|
4
4
|
export type Service = {
|
|
5
5
|
/** Base URL of the upstream service (e.g. `'https://api.openai.com'`). */
|
|
6
6
|
baseUrl: string
|
|
7
|
+
/** Free-form service categories for discovery metadata. */
|
|
8
|
+
categories?: string[] | undefined
|
|
7
9
|
/** Short description of the service. */
|
|
8
10
|
description?: string | undefined
|
|
11
|
+
/** Structured service documentation links for discovery metadata. */
|
|
12
|
+
docs?: Docs | undefined
|
|
9
13
|
/** Unique identifier used as the URL prefix (e.g. `'openai'` → `/{id}/...`). */
|
|
10
14
|
id: string
|
|
11
|
-
/** Returns a documentation URL. Called with no argument for the service root, or with a route pattern for per-endpoint docs. */
|
|
12
|
-
docsLlmsUrl?: ((options: { route?: string | undefined }) => string | undefined) | undefined
|
|
13
15
|
/** Hook to modify the upstream request before sending (e.g. inject auth headers). */
|
|
14
16
|
rewriteRequest?: ((req: Request, ctx: Context) => Request | Promise<Request>) | undefined
|
|
15
17
|
/** Hook to modify the upstream response before returning to the client. */
|
|
@@ -20,6 +22,12 @@ export type Service = {
|
|
|
20
22
|
title?: string | undefined
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
export type Docs = {
|
|
26
|
+
apiReference?: string | undefined
|
|
27
|
+
homepage?: string | undefined
|
|
28
|
+
llms?: string | undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
23
31
|
/**
|
|
24
32
|
* An endpoint definition.
|
|
25
33
|
*
|
|
@@ -80,9 +88,10 @@ export function from<options = unknown>(id: string, config: from.Config<options>
|
|
|
80
88
|
const rewriteFromConfig = resolveRewriteRequest(config)
|
|
81
89
|
return {
|
|
82
90
|
baseUrl: config.baseUrl,
|
|
91
|
+
categories: config.categories,
|
|
83
92
|
description: config.description,
|
|
93
|
+
docs: resolveDocs(config),
|
|
84
94
|
id,
|
|
85
|
-
docsLlmsUrl: resolveLlmsUrl(config.docsLlmsUrl),
|
|
86
95
|
routes: config.routes,
|
|
87
96
|
title: config.title,
|
|
88
97
|
rewriteRequest: config.rewriteRequest
|
|
@@ -102,8 +111,12 @@ export declare namespace from {
|
|
|
102
111
|
baseUrl: string
|
|
103
112
|
/** Shorthand: inject `Authorization: Bearer {token}` header. */
|
|
104
113
|
bearer?: string | undefined
|
|
114
|
+
/** Free-form service categories for discovery metadata. */
|
|
115
|
+
categories?: string[] | undefined
|
|
105
116
|
/** Short description of the service. */
|
|
106
117
|
description?: string | undefined
|
|
118
|
+
/** Structured service documentation links for discovery metadata. */
|
|
119
|
+
docs?: Docs | undefined
|
|
107
120
|
/** Shorthand: inject custom headers. */
|
|
108
121
|
headers?: Record<string, string> | undefined
|
|
109
122
|
/** Documentation URL for the service. String for a static base URL, or a function receiving an optional endpoint pattern. */
|
|
@@ -157,32 +170,14 @@ function resolveRewriteRequest(
|
|
|
157
170
|
return undefined
|
|
158
171
|
}
|
|
159
172
|
|
|
160
|
-
/** Serializes a service for discovery responses. */
|
|
161
|
-
export function serialize(s: Service) {
|
|
162
|
-
return {
|
|
163
|
-
description: s.description,
|
|
164
|
-
id: s.id,
|
|
165
|
-
docsLlmsUrl: s.docsLlmsUrl?.({}),
|
|
166
|
-
routes: Object.entries(s.routes).map(([pattern, endpoint]) => {
|
|
167
|
-
const tokens = pattern.trim().split(/\s+/)
|
|
168
|
-
const hasMethod = tokens.length >= 2
|
|
169
|
-
const path = hasMethod ? tokens.slice(1).join(' ') : tokens[0]
|
|
170
|
-
return {
|
|
171
|
-
docsLlmsUrl: s.docsLlmsUrl?.({ route: pattern }),
|
|
172
|
-
method: hasMethod ? tokens[0] : undefined,
|
|
173
|
-
path: `/${s.id}${path}`,
|
|
174
|
-
pattern: hasMethod ? `${tokens[0]} /${s.id}${path}` : `/${s.id}${path}`,
|
|
175
|
-
payment: endpoint ? resolvePayment(endpoint) : null,
|
|
176
|
-
}
|
|
177
|
-
}),
|
|
178
|
-
title: s.title,
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
173
|
/** Renders an llms.txt markdown string for a list of services. */
|
|
183
174
|
export function toLlmsTxt(
|
|
184
175
|
services: Service[],
|
|
185
|
-
options?: {
|
|
176
|
+
options?: {
|
|
177
|
+
description?: string | undefined
|
|
178
|
+
openApiPath?: string | undefined
|
|
179
|
+
title?: string | undefined
|
|
180
|
+
},
|
|
186
181
|
): string {
|
|
187
182
|
const lines: string[] = [
|
|
188
183
|
`# ${options?.title ?? 'API Proxy'}`,
|
|
@@ -197,65 +192,13 @@ export function toLlmsTxt(
|
|
|
197
192
|
for (const s of services) {
|
|
198
193
|
const label = s.title ?? s.id
|
|
199
194
|
const desc = s.description ? `: ${s.description}` : ''
|
|
200
|
-
lines.push(`-
|
|
195
|
+
lines.push(`- ${label}${desc}`)
|
|
201
196
|
}
|
|
202
|
-
lines.push('',
|
|
197
|
+
lines.push('', `[OpenAPI discovery](${options?.openApiPath ?? '/openapi.json'})`)
|
|
203
198
|
|
|
204
199
|
return lines.join('\n')
|
|
205
200
|
}
|
|
206
201
|
|
|
207
|
-
/** Renders a full markdown listing of all services with their routes. */
|
|
208
|
-
export function toServicesMarkdown(services: Service[]): string {
|
|
209
|
-
const lines: string[] = ['# Services', '']
|
|
210
|
-
|
|
211
|
-
if (services.length === 0) return lines.join('\n')
|
|
212
|
-
|
|
213
|
-
for (const s of services) {
|
|
214
|
-
lines.push(`## [${s.title ?? s.id}](/discover/${s.id}.md)`, '')
|
|
215
|
-
if (s.description) lines.push(s.description, '')
|
|
216
|
-
pushRoutes(lines, s)
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
return lines.join('\n')
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/** Renders a markdown string for a single service. */
|
|
223
|
-
export function toMarkdown(s: Service): string {
|
|
224
|
-
const docsLlmsUrl = s.docsLlmsUrl?.({})
|
|
225
|
-
const lines: string[] = [`# ${s.title ?? s.id}`, '']
|
|
226
|
-
if (docsLlmsUrl) lines.push(`> Documentation: ${docsLlmsUrl}`, '')
|
|
227
|
-
if (s.description) lines.push(s.description, '')
|
|
228
|
-
pushRoutes(lines, s, '##')
|
|
229
|
-
return lines.join('\n')
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function pushRoutes(lines: string[], s: Service, heading: '##' | '###' = '###') {
|
|
233
|
-
lines.push(`${heading} Routes`, '')
|
|
234
|
-
const serialized = serialize(s)
|
|
235
|
-
for (const route of serialized.routes) {
|
|
236
|
-
const p = route.payment as Record<string, unknown> | null
|
|
237
|
-
const desc = p?.description ? `: ${p.description}` : ''
|
|
238
|
-
lines.push(`- \`${route.pattern}\`${desc}`)
|
|
239
|
-
if (!p) {
|
|
240
|
-
lines.push(' - Type: free')
|
|
241
|
-
} else {
|
|
242
|
-
lines.push(` - Type: ${p.intent}`)
|
|
243
|
-
if (p.amount) {
|
|
244
|
-
const perUnit = p.unitType ? `/${p.unitType}` : ''
|
|
245
|
-
if (p.decimals !== undefined) {
|
|
246
|
-
const price = Number(p.amount) / 10 ** Number(p.decimals)
|
|
247
|
-
lines.push(` - Price: ${price}${perUnit} (${p.amount} units, ${p.decimals} decimals)`)
|
|
248
|
-
} else {
|
|
249
|
-
lines.push(` - Units: ${p.amount}${perUnit}`)
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
if (p.currency) lines.push(` - Currency: ${p.currency}`)
|
|
253
|
-
}
|
|
254
|
-
if (route.docsLlmsUrl) lines.push(` - Docs: ${route.docsLlmsUrl}`)
|
|
255
|
-
lines.push('')
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
202
|
/** Extracts per-endpoint options from an endpoint definition. */
|
|
260
203
|
export function getOptions(endpoint: Endpoint): EndpointOptions | undefined {
|
|
261
204
|
if (typeof endpoint === 'object' && endpoint !== null && 'options' in endpoint)
|
|
@@ -263,10 +206,10 @@ export function getOptions(endpoint: Endpoint): EndpointOptions | undefined {
|
|
|
263
206
|
return undefined
|
|
264
207
|
}
|
|
265
208
|
|
|
266
|
-
function
|
|
209
|
+
export function paymentOf(endpoint: Endpoint): Record<string, unknown> | null {
|
|
267
210
|
if (endpoint === true) return null
|
|
268
211
|
const handler = typeof endpoint === 'function' ? endpoint : endpoint.pay
|
|
269
|
-
if (!('_internal' in handler)) return
|
|
212
|
+
if (!('_internal' in handler)) return null
|
|
270
213
|
const {
|
|
271
214
|
name,
|
|
272
215
|
intent,
|
|
@@ -283,10 +226,21 @@ function resolvePayment(endpoint: Endpoint): Record<string, unknown> | null {
|
|
|
283
226
|
return { intent, method: name, ...rest, ...(amount !== undefined && { amount }) }
|
|
284
227
|
}
|
|
285
228
|
|
|
286
|
-
function
|
|
229
|
+
function resolveDocs(config: from.Config): Docs | undefined {
|
|
230
|
+
if (config.docs) {
|
|
231
|
+
return {
|
|
232
|
+
...config.docs,
|
|
233
|
+
...(config.docs.llms ? {} : { llms: resolveLlmsFromLegacy(config.docsLlmsUrl) }),
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const llms = resolveLlmsFromLegacy(config.docsLlmsUrl)
|
|
237
|
+
return llms ? { llms } : undefined
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function resolveLlmsFromLegacy(
|
|
287
241
|
input: string | ((options: { route?: string | undefined }) => string | undefined) | undefined,
|
|
288
|
-
):
|
|
242
|
+
): string | undefined {
|
|
289
243
|
if (!input) return undefined
|
|
290
|
-
if (typeof input === '
|
|
291
|
-
return ({
|
|
244
|
+
if (typeof input === 'string') return input
|
|
245
|
+
return input({}) ?? undefined
|
|
292
246
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, test } from '
|
|
1
|
+
import { describe, expect, test } from 'vp/test'
|
|
2
2
|
|
|
3
3
|
import * as Route from './Route.js'
|
|
4
4
|
|
|
@@ -24,6 +24,14 @@ describe('pathname', () => {
|
|
|
24
24
|
Route.pathname(new URL('http://localhost/other/openai/v1/models'), '/api/proxy'),
|
|
25
25
|
).toBeNull()
|
|
26
26
|
})
|
|
27
|
+
|
|
28
|
+
test('error: returns null for basePath prefix collision', () => {
|
|
29
|
+
expect(Route.pathname(new URL('http://localhost/proxy2/openai/v1/models'), '/proxy')).toBeNull()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('behavior: returns empty string when pathname equals basePath', () => {
|
|
33
|
+
expect(Route.pathname(new URL('http://localhost/proxy'), '/proxy')).toBe('')
|
|
34
|
+
})
|
|
27
35
|
})
|
|
28
36
|
|
|
29
37
|
describe('parse', () => {
|
|
@@ -5,7 +5,7 @@ export function pathname(url: URL, basePath?: string): string | null {
|
|
|
5
5
|
let pathname = url.pathname
|
|
6
6
|
if (basePath) {
|
|
7
7
|
const base = basePath.replace(/\/+$/, '')
|
|
8
|
-
if (!pathname.startsWith(base)) return null
|
|
8
|
+
if (!(pathname === base || pathname.startsWith(`${base}/`))) return null
|
|
9
9
|
pathname = pathname.slice(base.length)
|
|
10
10
|
}
|
|
11
11
|
return pathname
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Receipt } from 'mppx'
|
|
2
|
+
import { Mppx as Mppx_client, tempo as tempo_client } from 'mppx/client'
|
|
3
|
+
import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
|
|
4
|
+
import { afterEach, describe, expect, test } from 'vp/test'
|
|
5
|
+
import * as Http from '~test/Http.js'
|
|
6
|
+
import { accounts, asset, client } from '~test/tempo/viem.js'
|
|
7
|
+
|
|
8
|
+
import * as ApiProxy from '../Proxy.js'
|
|
9
|
+
import { anthropic } from './anthropic.js'
|
|
10
|
+
|
|
11
|
+
const apiKey = 'sk-ant-test-fake-anthropic-key'
|
|
12
|
+
const secretKey = 'test-secret-key'
|
|
13
|
+
|
|
14
|
+
const mppx_server = Mppx_server.create({
|
|
15
|
+
methods: [
|
|
16
|
+
tempo_server({
|
|
17
|
+
account: accounts[0],
|
|
18
|
+
currency: asset,
|
|
19
|
+
getClient: () => client,
|
|
20
|
+
}),
|
|
21
|
+
],
|
|
22
|
+
secretKey,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const mppx_client = Mppx_client.create({
|
|
26
|
+
polyfill: false,
|
|
27
|
+
methods: [
|
|
28
|
+
tempo_client({
|
|
29
|
+
account: accounts[1],
|
|
30
|
+
getClient: () => client,
|
|
31
|
+
}),
|
|
32
|
+
],
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
let proxyServer: Awaited<ReturnType<typeof Http.createServer>> | undefined
|
|
36
|
+
let upstreamServer: Awaited<ReturnType<typeof Http.createServer>> | undefined
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
proxyServer?.close()
|
|
40
|
+
upstreamServer?.close()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('anthropic', () => {
|
|
44
|
+
test('behavior: proxies POST /v1/messages with charge and injects x-api-key', async () => {
|
|
45
|
+
upstreamServer = await Http.createServer((req, res) => {
|
|
46
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
47
|
+
res.end(
|
|
48
|
+
JSON.stringify({
|
|
49
|
+
headers: {
|
|
50
|
+
'x-api-key': req.headers['x-api-key'],
|
|
51
|
+
},
|
|
52
|
+
}),
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const proxy = ApiProxy.create({
|
|
57
|
+
services: [
|
|
58
|
+
anthropic({
|
|
59
|
+
apiKey,
|
|
60
|
+
baseUrl: upstreamServer.url,
|
|
61
|
+
routes: {
|
|
62
|
+
'POST /v1/messages': mppx_server.charge({ amount: '1', decimals: 6 }),
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
],
|
|
66
|
+
})
|
|
67
|
+
proxyServer = await Http.createServer(proxy.listener)
|
|
68
|
+
|
|
69
|
+
const res = await mppx_client.fetch(`${proxyServer.url}/anthropic/v1/messages`, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: { 'Content-Type': 'application/json' },
|
|
72
|
+
body: JSON.stringify({ model: 'claude-3-opus-20240229', max_tokens: 1, messages: [] }),
|
|
73
|
+
})
|
|
74
|
+
expect(res.status).toBe(200)
|
|
75
|
+
|
|
76
|
+
const body = (await res.json()) as { headers: { 'x-api-key': string } }
|
|
77
|
+
expect(body.headers['x-api-key']).toBe(apiKey)
|
|
78
|
+
|
|
79
|
+
const receipt = Receipt.fromResponse(res)
|
|
80
|
+
expect(receipt.status).toBe('success')
|
|
81
|
+
expect(receipt.method).toBe('tempo')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('behavior: returns 402 without credential', async () => {
|
|
85
|
+
upstreamServer = await Http.createServer((_req, res) => {
|
|
86
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
87
|
+
res.end('{}')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const proxy = ApiProxy.create({
|
|
91
|
+
services: [
|
|
92
|
+
anthropic({
|
|
93
|
+
apiKey,
|
|
94
|
+
baseUrl: upstreamServer.url,
|
|
95
|
+
routes: {
|
|
96
|
+
'POST /v1/messages': mppx_server.charge({ amount: '1', decimals: 6 }),
|
|
97
|
+
},
|
|
98
|
+
}),
|
|
99
|
+
],
|
|
100
|
+
})
|
|
101
|
+
proxyServer = await Http.createServer(proxy.listener)
|
|
102
|
+
|
|
103
|
+
const res = await fetch(`${proxyServer.url}/anthropic/v1/messages`, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
})
|
|
106
|
+
expect(res.status).toBe(402)
|
|
107
|
+
expect(res.headers.get('WWW-Authenticate')).toContain('Payment')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('behavior: returns 404 for unmatched route', async () => {
|
|
111
|
+
upstreamServer = await Http.createServer((_req, res) => {
|
|
112
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
113
|
+
res.end('{}')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const proxy = ApiProxy.create({
|
|
117
|
+
services: [
|
|
118
|
+
anthropic({
|
|
119
|
+
apiKey,
|
|
120
|
+
baseUrl: upstreamServer.url,
|
|
121
|
+
routes: {
|
|
122
|
+
'POST /v1/messages': mppx_server.charge({ amount: '1', decimals: 6 }),
|
|
123
|
+
},
|
|
124
|
+
}),
|
|
125
|
+
],
|
|
126
|
+
})
|
|
127
|
+
proxyServer = await Http.createServer(proxy.listener)
|
|
128
|
+
|
|
129
|
+
const res = await fetch(`${proxyServer.url}/anthropic/v1/unknown`)
|
|
130
|
+
expect(res.status).toBe(404)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -20,7 +20,12 @@ import * as Service from '../Service.js'
|
|
|
20
20
|
export function anthropic(config: anthropic.Config) {
|
|
21
21
|
return Service.from<anthropic.Config>('anthropic', {
|
|
22
22
|
baseUrl: config.baseUrl ?? 'https://api.anthropic.com',
|
|
23
|
+
categories: ['ai'],
|
|
23
24
|
description: 'Claude language models for messages and completions.',
|
|
25
|
+
docs: {
|
|
26
|
+
apiReference: 'https://docs.anthropic.com/en/api/getting-started',
|
|
27
|
+
homepage: 'https://docs.anthropic.com/en/docs/intro-to-claude',
|
|
28
|
+
},
|
|
24
29
|
rewriteRequest(request, ctx) {
|
|
25
30
|
const apiKey = ctx.apiKey ?? config.apiKey
|
|
26
31
|
request.headers.set('x-api-key', apiKey)
|
|
@@ -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 '
|
|
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
|
|
|
@@ -20,11 +20,13 @@ import * as Service from '../Service.js'
|
|
|
20
20
|
export function openai(config: openai.Config) {
|
|
21
21
|
return Service.from<openai.Config>('openai', {
|
|
22
22
|
baseUrl: config.baseUrl ?? 'https://api.openai.com',
|
|
23
|
+
categories: ['ai'],
|
|
23
24
|
description: 'Chat completions, embeddings, image generation, and audio transcription.',
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
docs: {
|
|
26
|
+
apiReference: 'https://platform.openai.com/docs/api-reference',
|
|
27
|
+
homepage: 'https://platform.openai.com/docs',
|
|
28
|
+
llms: 'https://context7.com/websites/platform_openai/llms.txt',
|
|
29
|
+
},
|
|
28
30
|
rewriteRequest(request, ctx) {
|
|
29
31
|
const apiKey = ctx.apiKey ?? config.apiKey
|
|
30
32
|
request.headers.set('Authorization', `Bearer ${apiKey}`)
|