@parallel-protocol/x402 0.6.0 → 0.7.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/README.md CHANGED
@@ -193,13 +193,14 @@ interface PaymentMiddlewareConfig {
193
193
 
194
194
  | Field | Type | Required | Description |
195
195
  |-------|------|----------|-------------|
196
- | `price` | `string \| bigint` | Yes | Amount required. A string like `"0.01"` is parsed with each accepted token's own decimals (or with `decimals` when set explicitly). Pass a `bigint` to skip parsing. |
196
+ | `price` | `string \| bigint \| (ctx) => string \| bigint` | Yes | Amount required. A string like `"0.01"` is parsed with each accepted token's own decimals (or with `decimals` when set explicitly). Pass a `bigint` to skip parsing. A function prices each request from its content, see [Per-request pricing](#per-request-pricing). |
197
197
  | `decimals` | `number` | No | Override for the decimals used to parse a string price. By default each accepted token is priced with its own decimals from the `@parallel-protocol/chains` catalog — a `"0.01"` price is correct whether paid in USDp (18) or USDC (6), no configuration. Only needed for a custom token outside the catalog; without it, an unknown token raises `X402ConfigError` rather than being silently guessed. |
198
198
  | `network` | `string` | Yes | Chain slug: `"ethereum"`, `"base"`, `"avalanche"`, `"hyperevm"`. |
199
199
  | `payTo` | `Address` | Yes | The merchant's receiving address. |
200
- | `acceptedTokens` | `Address[]` | No | List of accepted token addresses. Defaults to `[USDp, USDC, sUSDp]` for known networks (see [Default tokens](#default-tokens)). |
200
+ | `acceptedTokens` | `Address[]` | No | List of accepted token addresses. Defaults to `[USDp, USDC, sUSDp]` for known networks (see [Default tokens](#default-tokens)). On an `upto` route it defaults to USDp alone, and any other token is rejected at construction: metered payments are USDp only. |
201
201
  | `description` | `string` | No | Human-readable description of the resource, included in the 402 body. |
202
202
  | `scheme` | `"exact" \| "upto"` | No | `exact` (default) charges `price` as invoiced. `upto` invoices `price` as a **cap** the payer signs; the handler names the actual amount to charge and the rest is refunded on-chain — see [Metered routes](#metered-routes-upto). |
203
+ | `meter` | `(ctx) => string \| bigint \| undefined` | No | `upto` routes only: reads the amount to charge from the handler's response, see [Reading the amount from the response](#reading-the-amount-from-the-response-meter). |
203
204
  | `maxTimeoutSeconds` | `number` | No | Settlement window the payer signs for (default 300). Metered routes announce how long their handler may take; the facilitator caps it at 3600 (one hour). Express, Hono and Next.js derive the handler timeout from it; Fastify does not time handlers out, set the server's `connectionTimeout` / `requestTimeout` yourself. |
204
205
 
205
206
  Route matching uses **longest prefix wins**: `/api/v1/users` will match a `/api/v1` route config before a `/api` one.
@@ -363,7 +364,7 @@ The `access-control-expose-headers` header is set automatically so browsers can
363
364
 
364
365
  ## Metered routes (`upto`)
365
366
 
366
- Some resources only know their cost after the work is done — an LLM completion is priced by the tokens it generates. A route declared with `scheme: "upto"` invoices `price` as a **cap**: the payer signs an authorization for that cap, the handler serves the request and names the amount it actually charges, and the facilitator settles that amount through Parallel's escrow contract, refunding the remainder to the payer in the same transaction. Metered payments are made in USDp.
367
+ Some resources only know their cost after the work is done — an LLM completion is priced by the tokens it generates. A route declared with `scheme: "upto"` invoices `price` as a **cap**: the payer signs an authorization for that cap, the handler serves the request and names the amount it actually charges, and the facilitator settles that amount through Parallel's escrow contract, refunding the remainder to the payer in the same transaction. Metered payments are made in USDp: `acceptedTokens` defaults to the network's USDp and may not list any other token.
367
368
 
368
369
  ```typescript
369
370
  routes: {
@@ -373,7 +374,6 @@ routes: {
373
374
  maxTimeoutSeconds: 600, // the handler may take up to ~9 minutes
374
375
  network: "base",
375
376
  payTo: "0xYourAddress",
376
- acceptedTokens: [PARALLEL_TOKENS.base.usdp],
377
377
  },
378
378
  },
379
379
  ```
@@ -399,10 +399,136 @@ The middleware strips the header before the response leaves, whatever the status
399
399
  | a decimal up to `price` | That amount is settled; the receipt's `amountSettled` carries it. |
400
400
  | malformed, more decimals than the token, or above `price` | The response is discarded and the client gets 402 `INVALID_SETTLE_AMOUNT` — a handler bug never charges the payer, and a positive amount the token cannot represent is never silently served for free. Round your cost to the token's precision before setting the header. |
401
401
 
402
+ ### Reading the amount from the response: `meter`
403
+
404
+ Instead of a header the handler sets, a route may carry a `meter` function that reads the amount from the response itself, so the handler stays a plain handler. It receives `{ status, header(name), body, request }`: the response status and headers, its JSON body as the middleware parsed it (`undefined` when the response is not JSON), and the request as the price function saw it. It returns an amount in the same format as `price`, or nothing:
405
+
406
+ ```typescript
407
+ "/v1/chat/completions": {
408
+ scheme: "upto",
409
+ price: "2",
410
+ network: "base",
411
+ payTo: "0xYourAddress",
412
+ meter: ({ body }) => (body as { usage?: { cost?: number } }).usage?.cost?.toFixed(6),
413
+ },
414
+ ```
415
+
416
+ | `meter` result | What happens |
417
+ |---|---|
418
+ | an amount up to `price` | That amount is settled. |
419
+ | an amount above `price` | **The cap is settled**: an overrun is the merchant's loss on that request, not a refused response. Size the cap so it does not happen. |
420
+ | `"0"` | The request is served for free, nothing is settled. |
421
+ | `undefined` or `null` | The `payment-settle-amount` header decides, with the rules above (absent charges the cap). |
422
+ | it throws, or returns something that is not an amount | The response is discarded and the client gets 402 `INVALID_SETTLE_AMOUNT`. |
423
+
424
+ The response body is read only on routes that have a `meter` function, from a clone or from the buffered chunks the middleware already holds, so other routes are untouched. Reading it gets the handler's own time budget: a body that streams from a slow upstream and is not complete within the window is treated like a failing meter. Return a buffered body on metered routes. `meter` only applies to `upto` routes.
425
+
402
426
  Timing: the payer's authorization expires after `maxTimeoutSeconds`, and settlement needs a minute at most. On Express, Hono and Next.js the handler timeout is raised to `maxTimeoutSeconds − 60 s` for metered routes; Fastify handlers are never timed out by the middleware, so size the server's own `connectionTimeout` and `requestTimeout` to the window. A handler that finishes after the window is served but cannot be settled: the client gets 402 `PAYMENT_SETTLEMENT_FAILED` and the merchant has done the work for nothing — keep a margin.
403
427
 
404
428
  ---
405
429
 
430
+ ## Per-request pricing
431
+
432
+ Some resources are priced by what is asked of them: an LLM completion by the messages sent and the `max_tokens` allowed. Give such a route a `price` function instead of a fixed amount. It receives the request and returns a price in the same format as a fixed `price`:
433
+
434
+ ```typescript
435
+ routes: {
436
+ "/v1/chat/completions": {
437
+ scheme: "upto",
438
+ network: "base",
439
+ payTo: "0xYourAddress",
440
+ price: ({ body }) => {
441
+ const { max_tokens = 1024 } = body as { max_tokens?: number };
442
+ return (max_tokens * 0.000002).toFixed(6); // the cap for this request, in USDp
443
+ },
444
+ },
445
+ },
446
+ ```
447
+
448
+ The function gets `{ method, path, url, header(name), body }`. `body` is the request body as the framework parsed it (`undefined` when there is none), so it is available where your framework parses it:
449
+
450
+ | Framework | `body` |
451
+ |-----------|--------|
452
+ | Fastify | Parsed by Fastify. Routes priced per request, or metered from their response, are gated at the `preHandler` stage (after parsing and schema validation); other routes keep gating at `onRequest`. |
453
+ | Express | `req.body`, so mount `express.json()` before `paymentMiddleware`. |
454
+ | Hono | Parsed with `c.req.json()` when the content type is JSON. Hono caches it, your handler can read it again. |
455
+ | Next.js | Parsed from a clone of the request when the content type is JSON; the handler keeps the original. |
456
+
457
+ The function runs twice per paid request: on the unpaid request, to build the 402, and on the paid retry, to size what the facilitator verifies. It must therefore be **pure**: same request, same price, no clock and no randomness. A retry whose body changed in between is priced again, and refused by the facilitator if the signed amount no longer covers the new price. It may be `async`.
458
+
459
+ A function that throws or returns something that is not a price is a merchant bug: the request fails with `X402ConfigError` rather than being served for free or mispriced. To refuse a request the route will not serve (an unsupported option, a resource that does not exist) throw `X402PriceRejectedError(code, message, status = 400)` instead: the client gets that status with `{ error: code, message }`, and nothing is invoiced or signed.
460
+
461
+ ---
462
+
463
+ ## LLM pricing (`@parallel-protocol/x402/llm`)
464
+
465
+ Everything a metered inference route needs, so the merchant writes no pricing or metering code: a price function that computes the cap from the request, a meter that reads the actual cost from the provider's response, and a relay to the provider.
466
+
467
+ ```typescript
468
+ import {
469
+ chatCompletionCap,
470
+ chatCompletionMeter,
471
+ forwardChatCompletion,
472
+ openRouterPrices,
473
+ } from "@parallel-protocol/x402/llm";
474
+
475
+ await app.register(
476
+ paymentMiddleware({
477
+ facilitator: { url: "https://agents.parallel.best" },
478
+ routes: {
479
+ "/v1/chat/completions": {
480
+ scheme: "upto",
481
+ network: "base",
482
+ payTo: "0xYourAddress",
483
+ maxTimeoutSeconds: 600,
484
+ price: chatCompletionCap({
485
+ prices: openRouterPrices(), // live prices, or a fixed table (below)
486
+ maxOutputTokens: 4096, // the largest completion this route pays for
487
+ models: ["deepseek/deepseek-chat", "anthropic/claude-sonnet-4.5"],
488
+ }),
489
+ meter: chatCompletionMeter(), // the provider's usage.cost, or tokens at the model's prices
490
+ },
491
+ },
492
+ }),
493
+ );
494
+
495
+ app.post("/v1/chat/completions", async (request, reply) => {
496
+ const upstream = await forwardChatCompletion(request.body, {
497
+ baseUrl: "https://openrouter.ai/api/v1",
498
+ apiKey: process.env.OPENROUTER_API_KEY,
499
+ maxOutputTokens: 4096, // the same bound as the cap
500
+ });
501
+ return reply.status(upstream.status).send(upstream.body);
502
+ });
503
+ ```
504
+
505
+ The same handler on the other frameworks: Express `res.status(upstream.status).json(upstream.body)`, Hono `c.json(upstream.body, upstream.status)`, Next.js `NextResponse.json(upstream.body, { status: upstream.status })`.
506
+
507
+ `forwardChatCompletion(body, { baseUrl, apiKey, maxOutputTokens, outputTokensField, timeout, headers, fetch, fields })` posts to `${baseUrl}/chat/completions` with only the fields the cap accounts for (`FORWARDED_FIELDS`: model, messages, tools, sampling parameters, reasoning options), `stream: false` and the completion bounded by `maxOutputTokens` (sent as `max_tokens`, or `max_completion_tokens` for providers that want it; a `reasoning.max_tokens` budget is bounded the same way), and returns the provider's `{ status, body }`: a 2xx and the statuses about the client's request (400, 404, 413, 422, 429 and the like) are handed back as they are, so nothing is settled on an error; a status about the merchant's account with the provider (401, 402, 403, 5xx) is folded into 502 `UPSTREAM_ERROR` with a fixed body, so the client never sees the merchant's provider details; a provider that cannot be reached answers 502 `UPSTREAM_UNAVAILABLE`, a timeout 504 `UPSTREAM_TIMEOUT` (five minutes by default).
508
+
509
+ `chatCompletionMeter({ prices? })` reads the amount from the response: `usage.cost` in dollars when the provider reports one (OpenRouter does), otherwise `usage.prompt_tokens` and `usage.completion_tokens` at the model's prices when `prices` is given, otherwise nothing, which charges the cap. So a provider that reports no cost (DeepSeek, most direct APIs) needs the same `prices` as the cap, or every request settles at the cap with nothing refunded and no error to show it. A zero cost serves the request for free. Cached input tokens are charged at the full input price in the token-count path.
510
+
511
+ The cap of a request is a bound on its input tokens at the model's input price, plus the largest completion the request may produce (`max_tokens`, bounded by `maxOutputTokens`, which also stands in for it when absent) at its output price. One USDp per dollar. The cap must cover the real count whichever tokenizer the provider uses, or the response is refused and the merchant has paid the provider for nothing, so the built-in bound counts one token per UTF-8 byte of the messages and tools, which no tokenizer exceeds. English prose runs at about four characters per token, so the cap is loose there: the payer only pays the provider's real count and the rest is refunded. A merchant who wants a tighter cap plugs a real tokenizer through `estimateInputTokens`.
512
+
513
+ | Option | Default | Description |
514
+ |--------|---------|-------------|
515
+ | `prices` | required | `openRouterPrices()` or a table `{ [model]: { input, output, maxOutputTokens? } }` in USD per million tokens. |
516
+ | `maxOutputTokens` | `4096` | Bound on the completion the cap pays for. **Forward the same bound to the provider**: a request may still ask for more. |
517
+ | `margin` | `0` | Extra margin on the input bound, for a merchant who replaced it with a tighter estimate. |
518
+ | `models` | all known | Models this route serves; others are refused with `MODEL_NOT_ALLOWED`. |
519
+ | `allowStreaming` | `false` | Streamed requests are refused unless set. A streamed response cannot carry the actual cost, so it is charged the cap. |
520
+ | `estimateInputTokens` | built-in | Replace the bound with a real tokenizer. |
521
+
522
+ A handler that does not use `forwardChatCompletion` should still forward only the fields the cap accounts for, never the raw body: options billed outside the token model are refused (`plugins`, `web_search_options`, `modalities` other than text) but a provider may add others. With `chatCompletionMeter`, a cost above the cap settles the cap; a handler that names more than the cap in `payment-settle-amount` instead has its response refused with `INVALID_SETTLE_AMOUNT`.
523
+
524
+ `openRouterPrices({ ttl: "24h", timeout: "10s" })` loads the public models listing (no API key) on first use, serves it from memory and refreshes it in the background once older than `ttl`, so no request waits for OpenRouter after the first one; a load is abandoned after `timeout`, a refresh that fails keeps the current list, and a failed load is not retried for five seconds. With no list at all, requests are refused with a 503 `PRICING_UNAVAILABLE`. A model the list does not know may have been released since the list was loaded: once the list is more than five minutes old, such a request refreshes it in the background and is refused with an `UNKNOWN_MODEL` message that says so, and the next request finds the model. A fixed table never touches the network: `prices: { "deepseek-flash": { input: 0.30, output: 1.20 } }`.
525
+
526
+ Requests the route cannot meter are refused before anything is invoiced, with a 400 and a JSON body: an unknown model (`UNKNOWN_MODEL`), a model outside `models` (`MODEL_NOT_ALLOWED`), more than one choice (`n`), non-text content, plugins, web search, image or audio output, or streaming (`UNSUPPORTED_OPTION`), a malformed body (`INVALID_REQUEST`). Live prices are the one accepted exception to the purity rule of price functions: a list refreshed between the unpaid request and the paid retry may re-price the retry, which is then refused and re-invoiced.
527
+
528
+ Without `meter`, what is charged after the response is the handler's call: set the `payment-settle-amount` header to the provider's cost, see [Metered routes](#metered-routes-upto).
529
+
530
+ ---
531
+
406
532
  ## Payment routes
407
533
 
408
534
  The facilitator supports 13 settlement routes. The route is selected automatically based on the signed payload:
@@ -440,6 +566,9 @@ import {
440
566
  // Middleware (framework-agnostic)
441
567
  createPaymentGate,
442
568
  createPaymentMiddleware,
569
+ resolveCharge,
570
+ resolveSettleAmount,
571
+ SETTLE_AMOUNT_HEADER,
443
572
 
444
573
  // Facilitator client
445
574
  FacilitatorClient,
@@ -447,6 +576,8 @@ import {
447
576
  // Errors
448
577
  X402ConfigError,
449
578
  X402RuntimeError,
579
+ X402PriceRejectedError,
580
+ isPriceRejection,
450
581
  X402_ERROR_CODES,
451
582
  type X402ErrorCode,
452
583
 
@@ -459,6 +590,8 @@ import {
459
590
  type FacilitatorConfig,
460
591
  type FacilitatorResponse,
461
592
  type HTTPAdapter,
593
+ type MeterContext,
594
+ type MeterFunction,
462
595
  type MiddlewareResult,
463
596
  type PassResult,
464
597
  type PaymentConfirmation,
@@ -467,6 +600,8 @@ import {
467
600
  type PaymentMiddlewareConfig,
468
601
  type PaymentRequired,
469
602
  type PaymentRequirements,
603
+ type PriceContext,
604
+ type PriceFunction,
470
605
  type ResourceInfo,
471
606
  type RouteConfig,
472
607
  type RunHandlerResult,
@@ -490,6 +625,33 @@ import {
490
625
 
491
626
  Each subpath also exports its adapter class (`ExpressAdapter`, `NextAdapter`, `FastifyAdapter`, `HonoAdapter`) for use with [`createPaymentGate`](#advanced-createpaymentgate).
492
627
 
628
+ ### LLM pricing (`@parallel-protocol/x402/llm`)
629
+
630
+ ```typescript
631
+ import {
632
+ chatCompletionCap, // the ready-made price function, see LLM pricing
633
+ chatCompletionMeter, // the ready-made meter: usage.cost, or tokens at the model's prices
634
+ forwardChatCompletion, // the relay to an OpenAI-compatible provider
635
+ FORWARDED_FIELDS, // the request fields the relay forwards
636
+ openRouterPrices, // live prices from OpenRouter, cached
637
+ staticPrices, // a fixed table in USD per million tokens
638
+ estimateInputTokens, // the built-in input token bound of a body
639
+ estimateTokens, // the bound of a piece of text
640
+ parseOpenRouterModels, // the models listing to a price table
641
+ parseDuration,
642
+ formatMicroUsd, // micro-dollars to an amount string
643
+ microFromUsd, // dollars to whole micro-dollars, rounded up
644
+ type ChatCompletionCapOptions,
645
+ type ChatCompletionMeterOptions,
646
+ type ChatCompletionBody,
647
+ type ForwardChatCompletionOptions,
648
+ type ForwardedResponse,
649
+ type ModelPrice,
650
+ type PriceSource,
651
+ type PriceTable,
652
+ } from "@parallel-protocol/x402/llm";
653
+ ```
654
+
493
655
  ---
494
656
 
495
657
  ## Advanced: `createPaymentGate`
@@ -577,6 +739,17 @@ A single-call `client.pay(paymentHeader, requirements)` (`POST /x402/pay`) also
577
739
  |-------------|------|
578
740
  | `X402ConfigError` | Invalid config at construction (bad `payTo`, bad facilitator URL, empty `routes`, non-positive `price`) — plus, on the first request to a route, an unknown network with no `acceptedTokens`, or an accepted token whose decimals are neither in the catalog nor set via `decimals` |
579
741
  | `X402RuntimeError` | Runtime failure; `.code` is one of the SDK codes below, or a facilitator error code propagated verbatim (see next table) |
742
+ | `X402PriceRejectedError` | Thrown by a price function to refuse a request before anything is invoiced; the client gets `.status` (400 by default) with `{ error: .code, message }`. `isPriceRejection(err)` recognises one by shape. |
743
+
744
+ Refusals `chatCompletionCap` makes, all before payment:
745
+
746
+ | `error` | Status | Meaning |
747
+ |---------|--------|---------|
748
+ | `INVALID_REQUEST` | 400 | No JSON object body, no `model`, no `messages`, or a `max_tokens` that is not a positive integer |
749
+ | `UNKNOWN_MODEL` | 400 | The price source has no price for this model |
750
+ | `MODEL_NOT_ALLOWED` | 400 | The model is outside the route's `models` list |
751
+ | `UNSUPPORTED_OPTION` | 400 | More than one choice, non-text content, plugins, web search, image or audio output, or streaming on a route that does not allow it |
752
+ | `PRICING_UNAVAILABLE` | 503 | Live prices could not be loaded yet; retry shortly |
580
753
 
581
754
  | `X402RuntimeError.code` | Meaning |
582
755
  |-------------------------|---------|