@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 +177 -4
- package/dist/{chunk-TLCEPWQI.js → chunk-AENXD4OC.js} +232 -79
- package/dist/chunk-AFRDHGDT.js +36 -0
- package/dist/chunk-JPMK5YBU.cjs +42 -0
- package/dist/{chunk-K6QETMQA.cjs → chunk-POU7EH3E.cjs} +254 -99
- package/dist/express.cjs +53 -4
- package/dist/express.d.cts +2 -1
- package/dist/express.d.ts +2 -1
- package/dist/express.js +50 -1
- package/dist/fastify.cjs +37 -12
- package/dist/fastify.d.cts +2 -1
- package/dist/fastify.d.ts +2 -1
- package/dist/fastify.js +33 -8
- package/dist/hono.cjs +21 -4
- package/dist/hono.d.cts +2 -1
- package/dist/hono.d.ts +2 -1
- package/dist/hono.js +18 -1
- package/dist/index.cjs +37 -24
- package/dist/index.d.cts +39 -5
- package/dist/index.d.ts +39 -5
- package/dist/index.js +2 -1
- package/dist/llm.cjs +486 -0
- package/dist/llm.d.cts +203 -0
- package/dist/llm.d.ts +203 -0
- package/dist/llm.js +473 -0
- package/dist/next.cjs +35 -14
- package/dist/next.d.cts +2 -1
- package/dist/next.d.ts +2 -1
- package/dist/next.js +29 -8
- package/dist/{types-CBzm5CtM.d.cts → types-q_gu5wjz.d.cts} +56 -2
- package/dist/{types-CBzm5CtM.d.ts → types-q_gu5wjz.d.ts} +56 -2
- package/package.json +11 -1
package/dist/llm.d.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { m as PriceFunction, g as MeterFunction } from './types-q_gu5wjz.js';
|
|
2
|
+
import 'viem';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Input token bound for an OpenAI-compatible chat completion, without a
|
|
6
|
+
* tokenizer. The cap the payer signs must cover the real count, whichever
|
|
7
|
+
* tokenizer the provider uses, or the response is refused and the merchant
|
|
8
|
+
* has paid the provider for nothing. Every tokenizer in use covers at least
|
|
9
|
+
* one byte of input per token (byte-level BPE, SentencePiece with byte
|
|
10
|
+
* fallback, character-level), so the number of UTF-8 bytes is a bound the
|
|
11
|
+
* real count never exceeds. English prose runs at about four characters per
|
|
12
|
+
* token, so the bound is loose there; the payer only ever pays the real
|
|
13
|
+
* count and the difference is refunded. A merchant who wants a tighter cap
|
|
14
|
+
* plugs a real tokenizer through `estimateInputTokens`.
|
|
15
|
+
*/
|
|
16
|
+
type ChatMessage = {
|
|
17
|
+
role?: string;
|
|
18
|
+
name?: string;
|
|
19
|
+
content?: unknown;
|
|
20
|
+
tool_calls?: unknown;
|
|
21
|
+
tool_call_id?: string;
|
|
22
|
+
};
|
|
23
|
+
interface ChatCompletionBody {
|
|
24
|
+
model?: unknown;
|
|
25
|
+
messages?: unknown;
|
|
26
|
+
tools?: unknown;
|
|
27
|
+
max_tokens?: unknown;
|
|
28
|
+
max_completion_tokens?: unknown;
|
|
29
|
+
n?: unknown;
|
|
30
|
+
stream?: unknown;
|
|
31
|
+
plugins?: unknown;
|
|
32
|
+
web_search_options?: unknown;
|
|
33
|
+
modalities?: unknown;
|
|
34
|
+
}
|
|
35
|
+
/** An upper bound on the tokens of a piece of text: its UTF-8 bytes. */
|
|
36
|
+
declare function estimateTokens(text: string): number;
|
|
37
|
+
/** A bound on the input tokens of a chat completion body: messages, then tools. */
|
|
38
|
+
declare function estimateInputTokens(body: ChatCompletionBody): number;
|
|
39
|
+
|
|
40
|
+
/** What a model costs, in USD per million tokens. */
|
|
41
|
+
interface ModelPrice {
|
|
42
|
+
input: number;
|
|
43
|
+
output: number;
|
|
44
|
+
/** Largest completion the provider serves for this model, when known. */
|
|
45
|
+
maxOutputTokens?: number;
|
|
46
|
+
}
|
|
47
|
+
/** A fixed price list keyed by model id. */
|
|
48
|
+
type PriceTable = Record<string, ModelPrice>;
|
|
49
|
+
/** Where the cap looks prices up. Resolves `undefined` for an unknown model. */
|
|
50
|
+
interface PriceSource {
|
|
51
|
+
get(model: string): Promise<ModelPrice | undefined>;
|
|
52
|
+
}
|
|
53
|
+
/** Prices written in the merchant's config: no network, no cache. */
|
|
54
|
+
declare function staticPrices(table: PriceTable): PriceSource;
|
|
55
|
+
interface OpenRouterPricesOptions {
|
|
56
|
+
/**
|
|
57
|
+
* How long a loaded price list is served before it is refreshed in the
|
|
58
|
+
* background: a number of milliseconds or a duration such as `"24h"`,
|
|
59
|
+
* `"1h"`, `"30m"`. Default `"24h"`. `0` refreshes on every request.
|
|
60
|
+
*/
|
|
61
|
+
ttl?: number | string;
|
|
62
|
+
/** The models endpoint. Default `https://openrouter.ai/api/v1/models`. */
|
|
63
|
+
url?: string;
|
|
64
|
+
/** The fetch implementation to use. Default: the global `fetch`. */
|
|
65
|
+
fetch?: typeof fetch;
|
|
66
|
+
/** How long one load may take before it is abandoned. Default `"10s"`. */
|
|
67
|
+
timeout?: number | string;
|
|
68
|
+
}
|
|
69
|
+
/** Parses `"24h"`, `"90m"`, `"30s"` or a number of milliseconds. */
|
|
70
|
+
declare function parseDuration(value: number | string): number;
|
|
71
|
+
/**
|
|
72
|
+
* Maps the models listing to a price table, dropping models without a plain
|
|
73
|
+
* decimal price. The listing names one provider's completion limit per
|
|
74
|
+
* model, but a model is served by several providers with different limits,
|
|
75
|
+
* so no output bound is taken from it: the route's `maxOutputTokens` rules.
|
|
76
|
+
*/
|
|
77
|
+
declare function parseOpenRouterModels(payload: unknown): PriceTable;
|
|
78
|
+
/**
|
|
79
|
+
* Live prices from OpenRouter's public models listing (no API key). Loaded on
|
|
80
|
+
* first use, then served from memory; once older than `ttl` the list is
|
|
81
|
+
* refreshed in the background while requests keep using the current one, so
|
|
82
|
+
* no request ever waits for OpenRouter after the first. A refresh that fails
|
|
83
|
+
* keeps the current list. With no list at all, requests are refused with a
|
|
84
|
+
* 503 until one loads.
|
|
85
|
+
*/
|
|
86
|
+
declare function openRouterPrices(options?: OpenRouterPricesOptions): PriceSource;
|
|
87
|
+
|
|
88
|
+
interface ChatCompletionCapOptions {
|
|
89
|
+
/** Model prices: a live source such as `openRouterPrices()` or a fixed table. */
|
|
90
|
+
prices: PriceSource | PriceTable;
|
|
91
|
+
/**
|
|
92
|
+
* Largest completion this route pays for. It bounds the request's
|
|
93
|
+
* `max_tokens` in the cap and stands in for it when absent, so the handler
|
|
94
|
+
* must forward the same bound to the provider. Default 4096.
|
|
95
|
+
*/
|
|
96
|
+
maxOutputTokens?: number;
|
|
97
|
+
/**
|
|
98
|
+
* Extra margin applied to the input token bound, for a merchant who
|
|
99
|
+
* replaces the built-in bound with a tighter estimate. Default 0.
|
|
100
|
+
*/
|
|
101
|
+
margin?: number;
|
|
102
|
+
/** Models this route serves. Default: every model the price source knows. */
|
|
103
|
+
models?: string[];
|
|
104
|
+
/**
|
|
105
|
+
* Accept streamed requests. Default false: refused before payment. A
|
|
106
|
+
* streamed response cannot carry the actual cost, so it is charged the cap.
|
|
107
|
+
*/
|
|
108
|
+
allowStreaming?: boolean;
|
|
109
|
+
/** Replaces the built-in input token bound, for a real tokenizer. */
|
|
110
|
+
estimateInputTokens?: (body: ChatCompletionBody) => number;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The cap of an OpenAI-compatible chat completion, computed from the request
|
|
114
|
+
* body: the input token bound (plus margin) at the model's input price, plus
|
|
115
|
+
* the largest completion the request may produce at its output price.
|
|
116
|
+
* Requests this route cannot meter are refused before anything is invoiced:
|
|
117
|
+
* an unknown or disallowed model, more than one choice, non-text content,
|
|
118
|
+
* options billed outside the token model (plugins, web search, image or
|
|
119
|
+
* audio output), and streaming unless allowed.
|
|
120
|
+
*/
|
|
121
|
+
declare function chatCompletionCap(options: ChatCompletionCapOptions): PriceFunction;
|
|
122
|
+
|
|
123
|
+
interface ForwardChatCompletionOptions {
|
|
124
|
+
/** The provider's OpenAI-compatible base URL, e.g. `https://openrouter.ai/api/v1`. */
|
|
125
|
+
baseUrl: string;
|
|
126
|
+
/** Sent as a bearer token when set. */
|
|
127
|
+
apiKey?: string;
|
|
128
|
+
/**
|
|
129
|
+
* Bound on the completion, forwarded as such: the same value the route's
|
|
130
|
+
* cap pays for. Default 4096.
|
|
131
|
+
*/
|
|
132
|
+
maxOutputTokens?: number;
|
|
133
|
+
/**
|
|
134
|
+
* The request field that carries the bound. OpenRouter and most providers
|
|
135
|
+
* take `max_tokens`; OpenAI's newer models want `max_completion_tokens`.
|
|
136
|
+
* Default `"max_tokens"`.
|
|
137
|
+
*/
|
|
138
|
+
outputTokensField?: "max_tokens" | "max_completion_tokens";
|
|
139
|
+
/** How long the provider may take before the call is abandoned. Default `"5m"`. */
|
|
140
|
+
timeout?: number | string;
|
|
141
|
+
/** Extra request headers (an app name, a referer). */
|
|
142
|
+
headers?: Record<string, string>;
|
|
143
|
+
/** The fetch implementation to use. Default: the global `fetch`. */
|
|
144
|
+
fetch?: typeof fetch;
|
|
145
|
+
/** The request fields forwarded to the provider. Default: `FORWARDED_FIELDS`. */
|
|
146
|
+
fields?: readonly string[];
|
|
147
|
+
}
|
|
148
|
+
interface ForwardedResponse {
|
|
149
|
+
status: number;
|
|
150
|
+
/** The provider's JSON, or its text when it is not JSON. */
|
|
151
|
+
body: unknown;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The request fields a metered route accounts for. The completion bound and
|
|
155
|
+
* `stream` are set separately; anything else in the body is dropped, so an
|
|
156
|
+
* option billed outside the token model never reaches the provider.
|
|
157
|
+
*/
|
|
158
|
+
declare const FORWARDED_FIELDS: readonly ["model", "messages", "tools", "tool_choice", "parallel_tool_calls", "response_format", "temperature", "top_p", "top_k", "min_p", "stop", "seed", "presence_penalty", "frequency_penalty", "repetition_penalty", "logit_bias", "reasoning", "reasoning_effort", "user"];
|
|
159
|
+
/**
|
|
160
|
+
* Forwards a chat completion to an OpenAI-compatible provider and returns
|
|
161
|
+
* its status and body, for a handler to send back as they are. Only the
|
|
162
|
+
* fields the cap accounts for are forwarded, with `stream: false` and the
|
|
163
|
+
* completion bounded by `maxOutputTokens` (a `reasoning.max_tokens` budget
|
|
164
|
+
* is bounded the same way). A provider error about the request keeps its
|
|
165
|
+
* status, so the middleware settles nothing; a provider error about the
|
|
166
|
+
* merchant's account, or a provider that cannot be reached, answers 502
|
|
167
|
+
* with a fixed body; a timeout answers 504.
|
|
168
|
+
*/
|
|
169
|
+
declare function forwardChatCompletion(body: unknown, options: ForwardChatCompletionOptions): Promise<ForwardedResponse>;
|
|
170
|
+
|
|
171
|
+
interface ChatCompletionMeterOptions {
|
|
172
|
+
/**
|
|
173
|
+
* Model prices, for providers whose responses carry token counts but no
|
|
174
|
+
* cost: `openRouterPrices()` or a fixed table in USD per million tokens.
|
|
175
|
+
*/
|
|
176
|
+
prices?: PriceSource | PriceTable;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The amount an OpenAI-compatible chat completion actually cost, read from
|
|
180
|
+
* the provider's response: `usage.cost` in dollars when the provider reports
|
|
181
|
+
* one (OpenRouter does), otherwise `usage.prompt_tokens` and
|
|
182
|
+
* `usage.completion_tokens` at the model's prices when a price source is
|
|
183
|
+
* given. With neither, no amount: the middleware falls back to the cap.
|
|
184
|
+
* One USDp per dollar. A zero cost serves the request for free. Whatever
|
|
185
|
+
* the provider sends, the result is an amount or nothing, never a failure.
|
|
186
|
+
*/
|
|
187
|
+
declare function chatCompletionMeter(options?: ChatCompletionMeterOptions): MeterFunction;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Micro-dollars to an amount string such as `"0.016875"` (1 USDp = 1 USD).
|
|
191
|
+
* Only a safe non-negative integer prints as an amount: anything else would
|
|
192
|
+
* come out in exponent form and be refused downstream.
|
|
193
|
+
*/
|
|
194
|
+
declare function formatMicroUsd(micro: number): string;
|
|
195
|
+
/**
|
|
196
|
+
* Dollars to whole micro-dollars, rounded up. The intermediate rounding to
|
|
197
|
+
* a thousandth of a micro-dollar removes the binary noise of the product
|
|
198
|
+
* (0.0005 dollars must be 500, not 501). An amount too large to print is
|
|
199
|
+
* bounded; the cap bounds it again downstream.
|
|
200
|
+
*/
|
|
201
|
+
declare function microFromUsd(dollars: number): number;
|
|
202
|
+
|
|
203
|
+
export { type ChatCompletionBody, type ChatCompletionCapOptions, type ChatCompletionMeterOptions, type ChatMessage, FORWARDED_FIELDS, type ForwardChatCompletionOptions, type ForwardedResponse, type ModelPrice, type OpenRouterPricesOptions, type PriceSource, type PriceTable, chatCompletionCap, chatCompletionMeter, estimateInputTokens, estimateTokens, formatMicroUsd, forwardChatCompletion, microFromUsd, openRouterPrices, parseDuration, parseOpenRouterModels, staticPrices };
|
package/dist/llm.js
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { X402ConfigError, X402PriceRejectedError } from './chunk-AFRDHGDT.js';
|
|
2
|
+
|
|
3
|
+
// src/llm/estimate.ts
|
|
4
|
+
function estimateTokens(text) {
|
|
5
|
+
let bytes = 0;
|
|
6
|
+
for (const char of text) {
|
|
7
|
+
const code = char.codePointAt(0) ?? 0;
|
|
8
|
+
bytes += code < 128 ? 1 : code < 2048 ? 2 : code < 65536 ? 3 : 4;
|
|
9
|
+
}
|
|
10
|
+
return bytes;
|
|
11
|
+
}
|
|
12
|
+
var TOKENS_PER_MESSAGE = 8;
|
|
13
|
+
var PRIMING_TOKENS = 3;
|
|
14
|
+
function messageText(message) {
|
|
15
|
+
const parts = [
|
|
16
|
+
String(message.role ?? ""),
|
|
17
|
+
String(message.name ?? "")
|
|
18
|
+
];
|
|
19
|
+
if (typeof message.content === "string") {
|
|
20
|
+
parts.push(message.content);
|
|
21
|
+
} else if (Array.isArray(message.content)) {
|
|
22
|
+
for (const part of message.content) {
|
|
23
|
+
if (typeof part?.text === "string") parts.push(part.text);
|
|
24
|
+
else if (part?.text != null) parts.push(String(part.text));
|
|
25
|
+
}
|
|
26
|
+
} else if (message.content != null) {
|
|
27
|
+
parts.push(JSON.stringify(message.content) ?? "");
|
|
28
|
+
}
|
|
29
|
+
if (message.tool_calls != null) {
|
|
30
|
+
parts.push(JSON.stringify(message.tool_calls) ?? "");
|
|
31
|
+
}
|
|
32
|
+
if (message.tool_call_id != null) parts.push(String(message.tool_call_id));
|
|
33
|
+
return parts.join(" ");
|
|
34
|
+
}
|
|
35
|
+
function estimateInputTokens(body) {
|
|
36
|
+
const messages = Array.isArray(body.messages) ? body.messages : [];
|
|
37
|
+
let tokens = PRIMING_TOKENS;
|
|
38
|
+
for (const message of messages) {
|
|
39
|
+
const text = typeof message === "object" && message !== null ? messageText(message) : String(message ?? "");
|
|
40
|
+
tokens += TOKENS_PER_MESSAGE + estimateTokens(text);
|
|
41
|
+
}
|
|
42
|
+
if (body.tools != null) {
|
|
43
|
+
tokens += estimateTokens(JSON.stringify(body.tools) ?? "");
|
|
44
|
+
}
|
|
45
|
+
return tokens;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/llm/money.ts
|
|
49
|
+
function formatMicroUsd(micro) {
|
|
50
|
+
if (!Number.isSafeInteger(micro) || micro < 0) {
|
|
51
|
+
throw new RangeError(`not a micro-dollar amount: ${micro}`);
|
|
52
|
+
}
|
|
53
|
+
const whole = Math.floor(micro / 1e6);
|
|
54
|
+
const fraction = String(micro % 1e6).padStart(6, "0");
|
|
55
|
+
return `${whole}.${fraction}`;
|
|
56
|
+
}
|
|
57
|
+
function clampMicro(micro) {
|
|
58
|
+
if (Number.isNaN(micro) || micro <= 0) return 0;
|
|
59
|
+
return Math.min(Math.ceil(micro), Number.MAX_SAFE_INTEGER);
|
|
60
|
+
}
|
|
61
|
+
function microFromUsd(dollars2) {
|
|
62
|
+
return clampMicro(Math.round(dollars2 * 1e9) / 1e3);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// src/llm/prices.ts
|
|
66
|
+
function lookup(table, model) {
|
|
67
|
+
return Object.hasOwn(table, model) ? table[model] : void 0;
|
|
68
|
+
}
|
|
69
|
+
function staticPrices(table) {
|
|
70
|
+
for (const [model, price] of Object.entries(table)) {
|
|
71
|
+
if (!isValidPrice(price)) {
|
|
72
|
+
throw new X402ConfigError(
|
|
73
|
+
`Invalid price for model "${model}": expected { input, output } in USD per million tokens`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { get: async (model) => lookup(table, model) };
|
|
78
|
+
}
|
|
79
|
+
function isPriceSource(value) {
|
|
80
|
+
return typeof value.get === "function";
|
|
81
|
+
}
|
|
82
|
+
function toPriceSource(value) {
|
|
83
|
+
return isPriceSource(value) ? value : staticPrices(value);
|
|
84
|
+
}
|
|
85
|
+
function positiveInteger(value) {
|
|
86
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : void 0;
|
|
87
|
+
}
|
|
88
|
+
var DEFAULT_MAX_OUTPUT_TOKENS = 4096;
|
|
89
|
+
function isValidPrice(price) {
|
|
90
|
+
if (typeof price !== "object" || price === null) return false;
|
|
91
|
+
const { input, output, maxOutputTokens } = price;
|
|
92
|
+
const rate = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
93
|
+
const bound = maxOutputTokens === void 0 || typeof maxOutputTokens === "number" && Number.isInteger(maxOutputTokens) && maxOutputTokens > 0;
|
|
94
|
+
return rate(input) && rate(output) && bound;
|
|
95
|
+
}
|
|
96
|
+
var DEFAULT_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
97
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
98
|
+
var RETRY_AFTER_MS = 5e3;
|
|
99
|
+
var MISS_REFRESH_MS = 5 * 6e4;
|
|
100
|
+
var OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
|
|
101
|
+
function shortName(value) {
|
|
102
|
+
return value.length > 80 ? `${value.slice(0, 77)}...` : value;
|
|
103
|
+
}
|
|
104
|
+
function parseDuration(value) {
|
|
105
|
+
if (typeof value === "number") {
|
|
106
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
107
|
+
throw new X402ConfigError(`Invalid duration: ${value}`);
|
|
108
|
+
}
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/i);
|
|
112
|
+
if (!match) {
|
|
113
|
+
throw new X402ConfigError(
|
|
114
|
+
`Invalid duration "${value}": expected a number of milliseconds or a value like "24h", "30m", "10s"`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const amount = Number(match[1]);
|
|
118
|
+
const unit = match[2].toLowerCase();
|
|
119
|
+
const factor = unit === "ms" ? 1 : unit === "s" ? 1e3 : unit === "m" ? 6e4 : unit === "h" ? 36e5 : 864e5;
|
|
120
|
+
return amount * factor;
|
|
121
|
+
}
|
|
122
|
+
var DECIMAL = /^\d+(?:\.\d+)?$/;
|
|
123
|
+
function perMillion(perToken) {
|
|
124
|
+
if (typeof perToken !== "string" || !DECIMAL.test(perToken)) return NaN;
|
|
125
|
+
const value = Number(perToken) * 1e6;
|
|
126
|
+
return Math.round(value * 1e9) / 1e9;
|
|
127
|
+
}
|
|
128
|
+
function parseOpenRouterModels(payload) {
|
|
129
|
+
const data = payload?.data;
|
|
130
|
+
if (!Array.isArray(data)) {
|
|
131
|
+
throw new Error("unexpected models payload: no `data` array");
|
|
132
|
+
}
|
|
133
|
+
const table = /* @__PURE__ */ Object.create(null);
|
|
134
|
+
for (const raw of data) {
|
|
135
|
+
if (typeof raw?.id !== "string" || typeof raw.pricing !== "object") {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const input = perMillion(raw.pricing?.prompt);
|
|
139
|
+
const output = perMillion(raw.pricing?.completion);
|
|
140
|
+
if (!Number.isFinite(input) || !Number.isFinite(output)) continue;
|
|
141
|
+
table[raw.id] = { input, output };
|
|
142
|
+
}
|
|
143
|
+
if (Object.keys(table).length === 0) {
|
|
144
|
+
throw new Error("unexpected models payload: no priced model");
|
|
145
|
+
}
|
|
146
|
+
return table;
|
|
147
|
+
}
|
|
148
|
+
function openRouterPrices(options = {}) {
|
|
149
|
+
const ttlMs = parseDuration(options.ttl ?? DEFAULT_TTL_MS);
|
|
150
|
+
const timeoutMs = parseDuration(options.timeout ?? DEFAULT_TIMEOUT_MS);
|
|
151
|
+
const url = options.url ?? OPENROUTER_MODELS_URL;
|
|
152
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
153
|
+
let table;
|
|
154
|
+
let loadedAt = 0;
|
|
155
|
+
let failedAt = 0;
|
|
156
|
+
let inflight;
|
|
157
|
+
const load = () => {
|
|
158
|
+
if (inflight) return inflight;
|
|
159
|
+
inflight = (async () => {
|
|
160
|
+
const res = await doFetch(url, {
|
|
161
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
162
|
+
});
|
|
163
|
+
if (!res.ok) throw new Error(`OpenRouter models: HTTP ${res.status}`);
|
|
164
|
+
table = parseOpenRouterModels(await res.json());
|
|
165
|
+
loadedAt = Date.now();
|
|
166
|
+
})().catch((cause) => {
|
|
167
|
+
failedAt = Date.now();
|
|
168
|
+
throw cause;
|
|
169
|
+
}).finally(() => {
|
|
170
|
+
inflight = void 0;
|
|
171
|
+
});
|
|
172
|
+
return inflight;
|
|
173
|
+
};
|
|
174
|
+
const canRetry = () => Date.now() - failedAt >= RETRY_AFTER_MS;
|
|
175
|
+
return {
|
|
176
|
+
async get(model) {
|
|
177
|
+
if (!table) {
|
|
178
|
+
if (canRetry()) await load().catch(() => {
|
|
179
|
+
});
|
|
180
|
+
if (!table) {
|
|
181
|
+
throw new X402PriceRejectedError(
|
|
182
|
+
"PRICING_UNAVAILABLE",
|
|
183
|
+
"Model prices are not available right now, retry shortly",
|
|
184
|
+
503
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
} else if (Date.now() - loadedAt >= ttlMs && canRetry()) {
|
|
188
|
+
load().catch(() => {
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
const price = lookup(table, model);
|
|
192
|
+
if (price) return price;
|
|
193
|
+
if (!inflight && Date.now() - loadedAt >= MISS_REFRESH_MS && canRetry()) {
|
|
194
|
+
load().catch(() => {
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
if (inflight) {
|
|
198
|
+
throw new X402PriceRejectedError(
|
|
199
|
+
"UNKNOWN_MODEL",
|
|
200
|
+
`No price known for model "${shortName(model)}" yet. The price list is being refreshed, retry in a few seconds.`
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
return void 0;
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// src/llm/cap.ts
|
|
209
|
+
var DEFAULT_MARGIN = 0;
|
|
210
|
+
function reject(code, message) {
|
|
211
|
+
throw new X402PriceRejectedError(code, message);
|
|
212
|
+
}
|
|
213
|
+
function isTextOnly(modalities) {
|
|
214
|
+
return Array.isArray(modalities) && modalities.every((modality) => modality === "text");
|
|
215
|
+
}
|
|
216
|
+
function chatCompletionCap(options) {
|
|
217
|
+
const source = toPriceSource(options.prices);
|
|
218
|
+
const maxOutputTokens = options.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
|
|
219
|
+
const margin = options.margin ?? DEFAULT_MARGIN;
|
|
220
|
+
const allowed = options.models ? new Set(options.models) : void 0;
|
|
221
|
+
const estimate = options.estimateInputTokens ?? estimateInputTokens;
|
|
222
|
+
if (positiveInteger(maxOutputTokens) === void 0) {
|
|
223
|
+
throw new X402ConfigError(
|
|
224
|
+
`chatCompletionCap: maxOutputTokens must be a positive integer, got ${maxOutputTokens}`
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
if (!Number.isFinite(margin) || margin < 0) {
|
|
228
|
+
throw new X402ConfigError(
|
|
229
|
+
`chatCompletionCap: margin must be a non-negative number, got ${margin}`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (allowed?.size === 0) {
|
|
233
|
+
throw new X402ConfigError("chatCompletionCap: models must not be empty");
|
|
234
|
+
}
|
|
235
|
+
return async (ctx) => {
|
|
236
|
+
const raw = ctx.body;
|
|
237
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
238
|
+
reject("INVALID_REQUEST", "Expected a JSON chat completion body");
|
|
239
|
+
}
|
|
240
|
+
const body = raw;
|
|
241
|
+
const model = body.model;
|
|
242
|
+
if (typeof model !== "string" || model.length === 0) {
|
|
243
|
+
reject("INVALID_REQUEST", "Expected a `model` in the request body");
|
|
244
|
+
}
|
|
245
|
+
if (!Array.isArray(body.messages) || body.messages.length === 0) {
|
|
246
|
+
reject("INVALID_REQUEST", "Expected a non-empty `messages` array");
|
|
247
|
+
}
|
|
248
|
+
if (allowed && !allowed.has(model)) {
|
|
249
|
+
reject(
|
|
250
|
+
"MODEL_NOT_ALLOWED",
|
|
251
|
+
`Model "${shortName(model)}" is not served here`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (body.n != null && body.n !== 1) {
|
|
255
|
+
reject("UNSUPPORTED_OPTION", "Only one choice per request (`n` = 1)");
|
|
256
|
+
}
|
|
257
|
+
if (body.stream != null && body.stream !== false && !options.allowStreaming) {
|
|
258
|
+
reject("UNSUPPORTED_OPTION", "Streaming is not supported on this route");
|
|
259
|
+
}
|
|
260
|
+
if (body.plugins != null || body.web_search_options != null) {
|
|
261
|
+
reject(
|
|
262
|
+
"UNSUPPORTED_OPTION",
|
|
263
|
+
"Plugins and web search are not supported on this route"
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
if (body.modalities != null && !isTextOnly(body.modalities)) {
|
|
267
|
+
reject(
|
|
268
|
+
"UNSUPPORTED_OPTION",
|
|
269
|
+
"Only text output is supported on this route"
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
for (const message of body.messages) {
|
|
273
|
+
if (!Array.isArray(message?.content)) continue;
|
|
274
|
+
for (const part of message.content) {
|
|
275
|
+
if (part?.type !== "text") {
|
|
276
|
+
reject(
|
|
277
|
+
"UNSUPPORTED_OPTION",
|
|
278
|
+
"Only text content is supported on this route"
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const requestedRaw = body.max_tokens ?? body.max_completion_tokens;
|
|
284
|
+
let requested;
|
|
285
|
+
if (requestedRaw != null) {
|
|
286
|
+
requested = positiveInteger(requestedRaw);
|
|
287
|
+
if (requested === void 0) {
|
|
288
|
+
reject("INVALID_REQUEST", "`max_tokens` must be a positive integer");
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const price = await source.get(model);
|
|
292
|
+
if (!price) {
|
|
293
|
+
reject("UNKNOWN_MODEL", `No price known for model "${shortName(model)}"`);
|
|
294
|
+
}
|
|
295
|
+
if (!isValidPrice(price)) {
|
|
296
|
+
throw new X402ConfigError(
|
|
297
|
+
`chatCompletionCap: the price source returned an invalid price for model "${shortName(model)}"`
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
const inputTokens = Math.ceil(estimate(body) * (1 + margin));
|
|
301
|
+
const outputTokens = Math.min(
|
|
302
|
+
requested ?? maxOutputTokens,
|
|
303
|
+
maxOutputTokens,
|
|
304
|
+
price.maxOutputTokens ?? Number.POSITIVE_INFINITY
|
|
305
|
+
);
|
|
306
|
+
const micro = Math.max(
|
|
307
|
+
1,
|
|
308
|
+
clampMicro(inputTokens * price.input + outputTokens * price.output)
|
|
309
|
+
);
|
|
310
|
+
return formatMicroUsd(micro);
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// src/llm/forward.ts
|
|
315
|
+
var FORWARDED_FIELDS = [
|
|
316
|
+
"model",
|
|
317
|
+
"messages",
|
|
318
|
+
"tools",
|
|
319
|
+
"tool_choice",
|
|
320
|
+
"parallel_tool_calls",
|
|
321
|
+
"response_format",
|
|
322
|
+
"temperature",
|
|
323
|
+
"top_p",
|
|
324
|
+
"top_k",
|
|
325
|
+
"min_p",
|
|
326
|
+
"stop",
|
|
327
|
+
"seed",
|
|
328
|
+
"presence_penalty",
|
|
329
|
+
"frequency_penalty",
|
|
330
|
+
"repetition_penalty",
|
|
331
|
+
"logit_bias",
|
|
332
|
+
"reasoning",
|
|
333
|
+
"reasoning_effort",
|
|
334
|
+
"user"
|
|
335
|
+
];
|
|
336
|
+
var DEFAULT_TIMEOUT_MS2 = 5 * 6e4;
|
|
337
|
+
var CLIENT_STATUSES = /* @__PURE__ */ new Set([400, 404, 405, 408, 409, 413, 415, 422, 429]);
|
|
338
|
+
var failure = (status, error, message) => ({
|
|
339
|
+
status,
|
|
340
|
+
body: { error, message }
|
|
341
|
+
});
|
|
342
|
+
async function forwardChatCompletion(body, options) {
|
|
343
|
+
if (typeof options.baseUrl !== "string" || !URL.canParse(options.baseUrl)) {
|
|
344
|
+
throw new X402ConfigError(
|
|
345
|
+
`forwardChatCompletion: baseUrl must be a URL, got ${JSON.stringify(options.baseUrl)}`
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
const maxOutputTokens = options.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
|
|
349
|
+
if (positiveInteger(maxOutputTokens) === void 0) {
|
|
350
|
+
throw new X402ConfigError(
|
|
351
|
+
`forwardChatCompletion: maxOutputTokens must be a positive integer, got ${maxOutputTokens}`
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
const timeoutMs = parseDuration(options.timeout ?? DEFAULT_TIMEOUT_MS2);
|
|
355
|
+
const fields = options.fields ?? FORWARDED_FIELDS;
|
|
356
|
+
const outputField = options.outputTokensField ?? "max_tokens";
|
|
357
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
358
|
+
const source = typeof body === "object" && body !== null && !Array.isArray(body) ? body : {};
|
|
359
|
+
const upstream = {};
|
|
360
|
+
for (const field of fields) {
|
|
361
|
+
if (Object.hasOwn(source, field) && source[field] !== void 0) {
|
|
362
|
+
upstream[field] = source[field];
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const requested = positiveInteger(source.max_tokens) ?? positiveInteger(source.max_completion_tokens);
|
|
366
|
+
const bound = Math.min(requested ?? maxOutputTokens, maxOutputTokens);
|
|
367
|
+
delete upstream.max_tokens;
|
|
368
|
+
delete upstream.max_completion_tokens;
|
|
369
|
+
upstream[outputField] = bound;
|
|
370
|
+
upstream.stream = false;
|
|
371
|
+
const reasoning = upstream.reasoning;
|
|
372
|
+
if (typeof reasoning === "object" && reasoning !== null) {
|
|
373
|
+
const budget = reasoning.max_tokens;
|
|
374
|
+
if (typeof budget === "number" && budget > bound) {
|
|
375
|
+
upstream.reasoning = { ...reasoning, max_tokens: bound };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
const url = `${options.baseUrl.replace(/\/+$/, "")}/chat/completions`;
|
|
379
|
+
let status;
|
|
380
|
+
let text;
|
|
381
|
+
try {
|
|
382
|
+
const res = await doFetch(url, {
|
|
383
|
+
method: "POST",
|
|
384
|
+
headers: {
|
|
385
|
+
"content-type": "application/json",
|
|
386
|
+
...options.apiKey && { authorization: `Bearer ${options.apiKey}` },
|
|
387
|
+
...options.headers
|
|
388
|
+
},
|
|
389
|
+
body: JSON.stringify(upstream),
|
|
390
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
391
|
+
});
|
|
392
|
+
status = res.status;
|
|
393
|
+
text = await res.text();
|
|
394
|
+
} catch (cause) {
|
|
395
|
+
return cause?.name === "TimeoutError" ? failure(
|
|
396
|
+
504,
|
|
397
|
+
"UPSTREAM_TIMEOUT",
|
|
398
|
+
"The model provider did not answer in time"
|
|
399
|
+
) : failure(
|
|
400
|
+
502,
|
|
401
|
+
"UPSTREAM_UNAVAILABLE",
|
|
402
|
+
"The model provider could not be reached"
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
if (status >= 200 && status < 300) {
|
|
406
|
+
let parsed = text;
|
|
407
|
+
try {
|
|
408
|
+
parsed = JSON.parse(text);
|
|
409
|
+
} catch {
|
|
410
|
+
}
|
|
411
|
+
return { status, body: parsed };
|
|
412
|
+
}
|
|
413
|
+
if (CLIENT_STATUSES.has(status)) {
|
|
414
|
+
let parsed = text;
|
|
415
|
+
try {
|
|
416
|
+
parsed = JSON.parse(text);
|
|
417
|
+
} catch {
|
|
418
|
+
}
|
|
419
|
+
return { status, body: parsed };
|
|
420
|
+
}
|
|
421
|
+
return failure(
|
|
422
|
+
502,
|
|
423
|
+
"UPSTREAM_ERROR",
|
|
424
|
+
"The model provider refused the request"
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// src/llm/meter.ts
|
|
429
|
+
var DECIMAL2 = /^\d+(?:\.\d+)?$/;
|
|
430
|
+
function nonNegativeInteger(value) {
|
|
431
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : void 0;
|
|
432
|
+
}
|
|
433
|
+
function dollars(value) {
|
|
434
|
+
const number = typeof value === "number" ? value : typeof value === "string" && DECIMAL2.test(value) ? Number(value) : void 0;
|
|
435
|
+
return number !== void 0 && Number.isFinite(number) && number >= 0 ? number : void 0;
|
|
436
|
+
}
|
|
437
|
+
function chatCompletionMeter(options = {}) {
|
|
438
|
+
const source = options.prices === void 0 ? void 0 : toPriceSource(options.prices);
|
|
439
|
+
return async ({ body, request }) => {
|
|
440
|
+
const response = body;
|
|
441
|
+
const usage = response?.usage;
|
|
442
|
+
if (typeof usage !== "object" || usage === null || Array.isArray(usage)) {
|
|
443
|
+
return void 0;
|
|
444
|
+
}
|
|
445
|
+
const { cost, prompt_tokens, completion_tokens } = usage;
|
|
446
|
+
const reported = dollars(cost);
|
|
447
|
+
if (reported !== void 0) return formatMicroUsd(microFromUsd(reported));
|
|
448
|
+
if (!source) return void 0;
|
|
449
|
+
const input = nonNegativeInteger(prompt_tokens);
|
|
450
|
+
const output = nonNegativeInteger(completion_tokens);
|
|
451
|
+
if (input === void 0 || output === void 0) return void 0;
|
|
452
|
+
const candidates = [
|
|
453
|
+
response?.model,
|
|
454
|
+
request.body?.model
|
|
455
|
+
].filter((model) => typeof model === "string");
|
|
456
|
+
for (const model of candidates) {
|
|
457
|
+
let price;
|
|
458
|
+
try {
|
|
459
|
+
price = await source.get(model);
|
|
460
|
+
} catch {
|
|
461
|
+
return void 0;
|
|
462
|
+
}
|
|
463
|
+
if (isValidPrice(price)) {
|
|
464
|
+
return formatMicroUsd(
|
|
465
|
+
clampMicro(input * price.input + output * price.output)
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return void 0;
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export { FORWARDED_FIELDS, chatCompletionCap, chatCompletionMeter, estimateInputTokens, estimateTokens, formatMicroUsd, forwardChatCompletion, microFromUsd, openRouterPrices, parseDuration, parseOpenRouterModels, staticPrices };
|