mtok-relay 0.2.8 → 0.3.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 +12 -0
- package/dist/mtok-relay.mjs +122 -39
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,6 +9,15 @@ event before it spends your upstream key. It caps the serve to the paid amount,
|
|
|
9
9
|
caches the completion for honest retries, and reports nothing back to the
|
|
10
10
|
platform. The market indexes the draw from Base events.
|
|
11
11
|
|
|
12
|
+
Before payment, the SDK posts `{ request }` to `/quote` to count the actual provider
|
|
13
|
+
input and reserve output at the offered prices. The reference CLI requires a
|
|
14
|
+
vLLM-compatible `POST {upstream}/tokenize` endpoint using the same model and chat
|
|
15
|
+
template as `{upstream}/v1/chat/completions`. An OpenAI-compatible inference endpoint
|
|
16
|
+
alone is insufficient. If tokenization is missing or fails, quotes and fresh paid
|
|
17
|
+
draws return 503 before inference; completed draws still replay. Other providers need
|
|
18
|
+
a programmatic `countInputTokens(request)` adapter in `createRelayRuntime(config)`.
|
|
19
|
+
Do not list a paid offer until its quote endpoint works with the actual provider.
|
|
20
|
+
|
|
12
21
|
The relay is dual-stack for rolling upgrades. Offers signed with
|
|
13
22
|
`requestHashScheme: "nonce-v1"` carry a buyer-generated 16-byte `requestNonce`;
|
|
14
23
|
their on-chain `requestHash` commits to
|
|
@@ -41,6 +50,9 @@ Common flags:
|
|
|
41
50
|
- `--out-price <usd/MTok>`: required positive local output cost used to bound the
|
|
42
51
|
serve from the verified paid amount.
|
|
43
52
|
- `--in-price <usd/MTok>`: positive local input cost. Defaults to `--out-price`.
|
|
53
|
+
- `--max-input-tokens <n>` / `RELAY_MAX_INPUT_TOKENS`: hard input ceiling, default 131072.
|
|
54
|
+
- `--max-output-tokens <n>` / `RELAY_MAX_OUTPUT_TOKENS`: output ceiling, default 32768.
|
|
55
|
+
Both ceilings must be positive safe integers; set them for the model you serve.
|
|
44
56
|
- `--redemption-file <path>`: durable at-most-one-upstream-attempt log for one host. The default is
|
|
45
57
|
`./.mtok-redemption.jsonl`. An empty path is rejected; an unwritable path makes
|
|
46
58
|
paid serves fail closed before upstream inference.
|
package/dist/mtok-relay.mjs
CHANGED
|
@@ -4692,8 +4692,13 @@ function readRelayConfig({ argv = process.argv.slice(2), env = process.env } = {
|
|
|
4692
4692
|
const payerDenylist = String(denylistRaw).split(",").map((a) => a.trim().toLowerCase()).filter(Boolean);
|
|
4693
4693
|
const maxOutputRaw = flag(argv, "--max-output-tokens") ?? env.RELAY_MAX_OUTPUT_TOKENS;
|
|
4694
4694
|
const maxOutputTokens = maxOutputRaw != null ? Number(maxOutputRaw) : void 0;
|
|
4695
|
-
if (maxOutputTokens != null && (!Number.
|
|
4696
|
-
throw new Error("--max-output-tokens must be a positive integer");
|
|
4695
|
+
if (maxOutputTokens != null && (!Number.isSafeInteger(maxOutputTokens) || maxOutputTokens < 1)) {
|
|
4696
|
+
throw new Error("--max-output-tokens must be a positive safe integer");
|
|
4697
|
+
}
|
|
4698
|
+
const maxInputRaw = flag(argv, "--max-input-tokens") ?? env.RELAY_MAX_INPUT_TOKENS;
|
|
4699
|
+
const maxInputTokens = maxInputRaw != null ? Number(maxInputRaw) : void 0;
|
|
4700
|
+
if (maxInputTokens != null && (!Number.isSafeInteger(maxInputTokens) || maxInputTokens < 1)) {
|
|
4701
|
+
throw new Error("--max-input-tokens must be a positive safe integer");
|
|
4697
4702
|
}
|
|
4698
4703
|
const trustedProxies = String(env.RELAY_TRUSTED_PROXIES ?? "").split(",").map((value) => value.trim()).filter(Boolean);
|
|
4699
4704
|
if (trustedProxies.some((value) => !isIP(value) || value.includes("%"))) throw new Error("RELAY_TRUSTED_PROXIES must contain exact IP addresses");
|
|
@@ -4745,6 +4750,7 @@ function readRelayConfig({ argv = process.argv.slice(2), env = process.env } = {
|
|
|
4745
4750
|
settlementAddr,
|
|
4746
4751
|
payerDenylist,
|
|
4747
4752
|
maxOutputTokens,
|
|
4753
|
+
maxInputTokens,
|
|
4748
4754
|
trustedProxies,
|
|
4749
4755
|
clientIpHeader,
|
|
4750
4756
|
maxConcurrentRequests,
|
|
@@ -4856,7 +4862,7 @@ function clientRateKey(req, trustedProxies = /* @__PURE__ */ new Set(), header =
|
|
|
4856
4862
|
const words = right === void 0 ? start : [...start, ...Array(8 - start.length - end.length).fill("0"), ...end];
|
|
4857
4863
|
return words.slice(0, 4).map((word) => word.padStart(4, "0")).join(":") + "::/64";
|
|
4858
4864
|
}
|
|
4859
|
-
function startRelayServer({ config, handleDraw }) {
|
|
4865
|
+
function startRelayServer({ config, handleDraw, handleQuote }) {
|
|
4860
4866
|
const callers = createLimiter({ max: config.maxRequestsPerMinute ?? 120 });
|
|
4861
4867
|
const aggregate = createLimiter({ max: config.maxTotalRequestsPerMinute ?? 1200 });
|
|
4862
4868
|
const trustedProxies = new Set((config.trustedProxies ?? []).map(canonicalIp).filter(Boolean));
|
|
@@ -4869,7 +4875,7 @@ function startRelayServer({ config, handleDraw }) {
|
|
|
4869
4875
|
res.once("finish", () => req.destroy());
|
|
4870
4876
|
send(res, status, { error });
|
|
4871
4877
|
};
|
|
4872
|
-
if (req.method !== "POST" ||
|
|
4878
|
+
if (req.method !== "POST" || !["/chunk", "/quote"].includes(req.url)) {
|
|
4873
4879
|
return refuse(404, "not found");
|
|
4874
4880
|
}
|
|
4875
4881
|
try {
|
|
@@ -4893,7 +4899,7 @@ function startRelayServer({ config, handleDraw }) {
|
|
|
4893
4899
|
if (body?.request == null) {
|
|
4894
4900
|
return send(res, 400, { error: "bad_request", detail: "need a DRAW (request); the legacy FUND lane is retired, pay per draw on-chain" });
|
|
4895
4901
|
}
|
|
4896
|
-
await handleDraw(body, res);
|
|
4902
|
+
await (req.url === "/quote" ? handleQuote : handleDraw)(body, res);
|
|
4897
4903
|
} catch (error) {
|
|
4898
4904
|
(config.log ?? console).error?.(`mtok relay: request failed (${error.message})`);
|
|
4899
4905
|
refuse(503, "relay_unavailable");
|
|
@@ -7755,33 +7761,33 @@ function configuredFeeAtomic({ sellerUsdAtomic, feeAddress, feeBps }) {
|
|
|
7755
7761
|
if (!feeAddress || bps === 0n) return 0n;
|
|
7756
7762
|
return (BigInt(sellerUsdAtomic || 0) * bps + 5000n) / 10000n;
|
|
7757
7763
|
}
|
|
7758
|
-
var MESSAGE_OVERHEAD_TOKENS = 4;
|
|
7759
|
-
var BYTES_PER_TOKEN_EST = 3.2;
|
|
7760
|
-
function estimateInputTokens(messages2) {
|
|
7761
|
-
const utf8 = new TextEncoder();
|
|
7762
|
-
let bytes = 0;
|
|
7763
|
-
let envelope = 3;
|
|
7764
|
-
for (const m of messages2 ?? []) {
|
|
7765
|
-
envelope += MESSAGE_OVERHEAD_TOKENS;
|
|
7766
|
-
bytes += utf8.encode(String(m?.role ?? "")).length;
|
|
7767
|
-
bytes += utf8.encode(typeof m?.content === "string" ? m.content : JSON.stringify(m?.content ?? null)).length;
|
|
7768
|
-
}
|
|
7769
|
-
return envelope + Math.ceil(bytes / BYTES_PER_TOKEN_EST);
|
|
7770
|
-
}
|
|
7771
7764
|
var DEFAULT_MAX_OUTPUT_TOKENS = 32768;
|
|
7772
|
-
|
|
7773
|
-
|
|
7774
|
-
|
|
7775
|
-
if (
|
|
7776
|
-
|
|
7765
|
+
var DEFAULT_MAX_INPUT_TOKENS = 131072;
|
|
7766
|
+
function withinInputLimit(inputTokens, inputCeil) {
|
|
7767
|
+
if (!Number.isSafeInteger(inputTokens) || inputTokens < 0) throw new TypeError("input token count must be a nonnegative safe integer");
|
|
7768
|
+
if (!Number.isSafeInteger(inputCeil) || inputCeil <= 0) throw new TypeError("input token ceiling must be a positive safe integer");
|
|
7769
|
+
return inputTokens <= inputCeil;
|
|
7770
|
+
}
|
|
7771
|
+
var offerPriceAtomic = (value) => BigInt(Math.ceil(Number(value) * 1e6));
|
|
7772
|
+
function boundServe({ inputTokens, budgetUsdAtomic, inPriceAtomic, outPriceAtomic, reqMax, inputCeil = DEFAULT_MAX_INPUT_TOKENS, contextCeil = DEFAULT_MAX_OUTPUT_TOKENS }) {
|
|
7773
|
+
const inputAllowed = withinInputLimit(inputTokens, inputCeil);
|
|
7774
|
+
if (!Number.isSafeInteger(contextCeil) || contextCeil <= 0) throw new TypeError("output token ceiling must be a positive safe integer");
|
|
7775
|
+
const budget = BigInt(budgetUsdAtomic);
|
|
7776
|
+
const inputPrice = BigInt(inPriceAtomic);
|
|
7777
|
+
const outputPrice = BigInt(outPriceAtomic);
|
|
7778
|
+
if (budget < 0n || inputPrice < 0n || outputPrice < 0n) throw new RangeError("token budget and prices must be nonnegative");
|
|
7779
|
+
const inputCost = BigInt(inputTokens) * inputPrice;
|
|
7780
|
+
const details = { inputTokens, inputCostUsd: Number(inputCost) / 1e12 };
|
|
7781
|
+
if (!inputAllowed) return { refuse: true, reason: "input_limit", ...details };
|
|
7782
|
+
const outputBudget = budget * 1000000n - inputCost;
|
|
7783
|
+
if (outputBudget <= 0n) return { refuse: true, reason: "input", ...details };
|
|
7784
|
+
if (outputPrice === 0n) return { refuse: true, reason: "output_price", ...details };
|
|
7777
7785
|
let maxTok = contextCeil;
|
|
7778
7786
|
if (Number(reqMax) > 0) maxTok = Math.min(maxTok, Math.floor(Number(reqMax)));
|
|
7779
|
-
|
|
7780
|
-
|
|
7781
|
-
}
|
|
7782
|
-
|
|
7783
|
-
if (maxTok < 1) return { refuse: true, reason: "output", estIn, estInCostUsd };
|
|
7784
|
-
return { refuse: false, maxTok, estIn, estInCostUsd };
|
|
7787
|
+
const fundedOutput = outputBudget / outputPrice;
|
|
7788
|
+
if (fundedOutput < BigInt(maxTok)) maxTok = Number(fundedOutput);
|
|
7789
|
+
if (maxTok < 1) return { refuse: true, reason: "output", ...details };
|
|
7790
|
+
return { refuse: false, maxTok, ...details };
|
|
7785
7791
|
}
|
|
7786
7792
|
function createServeCore({
|
|
7787
7793
|
model,
|
|
@@ -7798,13 +7804,40 @@ function createServeCore({
|
|
|
7798
7804
|
feeRecipient,
|
|
7799
7805
|
feeBps,
|
|
7800
7806
|
screenPayer,
|
|
7807
|
+
// The host counts its actual sanitized provider input. Missing or failed
|
|
7808
|
+
// accounting refuses a fresh claim; saved completions do not need recounting.
|
|
7809
|
+
countInputTokens,
|
|
7810
|
+
maxInputTokens = DEFAULT_MAX_INPUT_TOKENS,
|
|
7801
7811
|
// #654: the output-token sanity ceiling for boundServe. The PAID budget already
|
|
7802
7812
|
// bounds output (and metering is on real usage), so this is a defensive cap on a
|
|
7803
7813
|
// single generation, not a money guard. It is a per-relay knob: the reference host
|
|
7804
7814
|
// passes MTOK_MAX_OUTPUT_TOKENS / a config value; operators serving large-context
|
|
7805
7815
|
// models raise it. Falls back to boundServe's own generous default when unset.
|
|
7806
|
-
maxOutputTokens
|
|
7816
|
+
maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS
|
|
7807
7817
|
}) {
|
|
7818
|
+
const quote = async (request) => {
|
|
7819
|
+
const checked = validateRequest(request, model);
|
|
7820
|
+
if (checked.error) return { status: 400, body: { error: "bad_request", detail: checked.error } };
|
|
7821
|
+
try {
|
|
7822
|
+
const inputTokens = await countInputTokens(checked.safeRequest);
|
|
7823
|
+
if (!withinInputLimit(inputTokens, maxInputTokens)) return { status: 413, body: { error: "input_too_large", detail: `input (${inputTokens} tokens) exceeds this relay's input-token limit` } };
|
|
7824
|
+
if (!Number.isSafeInteger(maxOutputTokens) || maxOutputTokens <= 0) throw new TypeError("output token ceiling must be a positive safe integer");
|
|
7825
|
+
const inputPrice = offerPriceAtomic(inPrice);
|
|
7826
|
+
const outputPrice = offerPriceAtomic(outPrice);
|
|
7827
|
+
if (inputPrice < 0n || outputPrice <= 0n) throw new RangeError("invalid offer prices");
|
|
7828
|
+
return { status: 200, body: {
|
|
7829
|
+
model,
|
|
7830
|
+
offerId,
|
|
7831
|
+
inputTokens,
|
|
7832
|
+
maxInputTokens,
|
|
7833
|
+
maxOutputTokens,
|
|
7834
|
+
inputPricePerMTokAtomic: inputPrice.toString(),
|
|
7835
|
+
outputPricePerMTokAtomic: outputPrice.toString()
|
|
7836
|
+
} };
|
|
7837
|
+
} catch (e) {
|
|
7838
|
+
return { status: 503, body: { error: "input_accounting_unavailable", detail: e.message } };
|
|
7839
|
+
}
|
|
7840
|
+
};
|
|
7808
7841
|
const serve = async (body) => {
|
|
7809
7842
|
const currentFeeRecipient = typeof feeRecipient === "function" ? feeRecipient() : feeRecipient;
|
|
7810
7843
|
const { bookingId, n, buyerId, request, requestNonce, drawPaidTxHash } = body;
|
|
@@ -7896,14 +7929,30 @@ function createServeCore({
|
|
|
7896
7929
|
if (remainingUsd < BALANCE_EPSILON) {
|
|
7897
7930
|
return { status: 402, body: { error: "balance_exhausted", detail: `remainingUsd=${remainingUsd}`, _bookingId: bookingId, remainingUsd } };
|
|
7898
7931
|
}
|
|
7899
|
-
|
|
7900
|
-
|
|
7901
|
-
|
|
7902
|
-
|
|
7903
|
-
|
|
7932
|
+
let bound;
|
|
7933
|
+
try {
|
|
7934
|
+
const inputTokens = await countInputTokens(checked.safeRequest);
|
|
7935
|
+
const price = (offered, committed) => {
|
|
7936
|
+
const local = offerPriceAtomic(offered);
|
|
7937
|
+
const paid2 = BigInt(committed);
|
|
7938
|
+
return local > paid2 ? local : paid2;
|
|
7939
|
+
};
|
|
7940
|
+
bound = boundServe({
|
|
7941
|
+
inputTokens,
|
|
7942
|
+
budgetUsdAtomic: paidEvent.sellerUsdAtomic,
|
|
7943
|
+
inPriceAtomic: price(inPrice, paidEvent.inputPricePerMTokAtomic),
|
|
7944
|
+
outPriceAtomic: price(outPrice, paidEvent.outputPricePerMTokAtomic),
|
|
7945
|
+
reqMax: checked.safeRequest.max_tokens,
|
|
7946
|
+
inputCeil: maxInputTokens,
|
|
7947
|
+
contextCeil: maxOutputTokens
|
|
7948
|
+
});
|
|
7949
|
+
} catch (e) {
|
|
7950
|
+
return { status: 503, body: { error: "input_accounting_unavailable", detail: e.message, _bookingId: bookingId } };
|
|
7951
|
+
}
|
|
7904
7952
|
if (bound.refuse) {
|
|
7905
|
-
const error = bound.reason === "input" ? "input_too_large" : "output_unfunded";
|
|
7906
|
-
|
|
7953
|
+
const error = bound.reason === "input" || bound.reason === "input_limit" ? "input_too_large" : "output_unfunded";
|
|
7954
|
+
const detail = bound.reason === "input_limit" ? `input (${bound.inputTokens} tokens) exceeds this relay's input-token limit` : `input (${bound.inputTokens} tokens, $${bound.inputCostUsd.toFixed(6)}) leaves no safely funded output in the paid amount ($${remainingUsd})`;
|
|
7955
|
+
return { status: 402, body: { error, detail, _bookingId: bookingId, remainingUsd } };
|
|
7907
7956
|
}
|
|
7908
7957
|
const safeRequest = { ...checked.safeRequest, model, max_tokens: bound.maxTok };
|
|
7909
7958
|
try {
|
|
@@ -7938,7 +7987,7 @@ function createServeCore({
|
|
|
7938
7987
|
}
|
|
7939
7988
|
return { status: 200, body: payload };
|
|
7940
7989
|
};
|
|
7941
|
-
return { serve };
|
|
7990
|
+
return { serve, quote };
|
|
7942
7991
|
}
|
|
7943
7992
|
|
|
7944
7993
|
// bridge/bridge.mjs
|
|
@@ -7965,6 +8014,27 @@ function httpUpstream({ baseUrl, key, timeoutMs }) {
|
|
|
7965
8014
|
return json;
|
|
7966
8015
|
};
|
|
7967
8016
|
}
|
|
8017
|
+
function httpInputCounter({ url, key, timeoutMs = 5e3 }) {
|
|
8018
|
+
return async ({ model, messages: messages2 }) => {
|
|
8019
|
+
const response = await fetch(url, {
|
|
8020
|
+
method: "POST",
|
|
8021
|
+
headers: { "content-type": "application/json", ...key ? { authorization: `Bearer ${key}` } : {} },
|
|
8022
|
+
body: JSON.stringify({ model, messages: messages2, add_generation_prompt: true, add_special_tokens: false }),
|
|
8023
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
8024
|
+
});
|
|
8025
|
+
if (!response.ok) throw new Error(`upstream tokenization ${response.status}`);
|
|
8026
|
+
const decoder = new TextDecoder();
|
|
8027
|
+
const chunks = [];
|
|
8028
|
+
let size3 = 0;
|
|
8029
|
+
for await (const chunk of response.body) {
|
|
8030
|
+
size3 += chunk.byteLength;
|
|
8031
|
+
if (size3 > 4 * 1024 * 1024) throw new Error("upstream tokenization response is too large");
|
|
8032
|
+
chunks.push(decoder.decode(chunk, { stream: true }));
|
|
8033
|
+
}
|
|
8034
|
+
chunks.push(decoder.decode());
|
|
8035
|
+
return JSON.parse(chunks.join("")).count;
|
|
8036
|
+
};
|
|
8037
|
+
}
|
|
7968
8038
|
|
|
7969
8039
|
// src/rpc.mjs
|
|
7970
8040
|
var BASE_MAINNET_RPCS = [
|
|
@@ -8008,6 +8078,7 @@ async function createRelayRuntime(config) {
|
|
|
8008
8078
|
const payerDenylist = new Set((config.payerDenylist ?? []).map((a) => String(a).trim().toLowerCase()).filter(Boolean));
|
|
8009
8079
|
const screenPayer = typeof config.screenPayer === "function" ? config.screenPayer : null;
|
|
8010
8080
|
const upstream = httpUpstream({ baseUrl: config.upstream + "/v1", key: config.upstreamKey, timeoutMs: config.upstreamTimeoutMs ?? 12e4 });
|
|
8081
|
+
const countInputTokens = config.countInputTokens ?? httpInputCounter({ url: config.upstream + "/tokenize", key: config.upstreamKey });
|
|
8011
8082
|
const payerDenied = async (payer) => {
|
|
8012
8083
|
if (!payer) return false;
|
|
8013
8084
|
if (payerDenylist.has(payer)) return true;
|
|
@@ -8033,6 +8104,8 @@ async function createRelayRuntime(config) {
|
|
|
8033
8104
|
},
|
|
8034
8105
|
// #654: per-relay output sanity ceiling (unset => the shared generous default).
|
|
8035
8106
|
maxOutputTokens: config.maxOutputTokens,
|
|
8107
|
+
maxInputTokens: config.maxInputTokens,
|
|
8108
|
+
countInputTokens,
|
|
8036
8109
|
screenPayer: payerDenied
|
|
8037
8110
|
});
|
|
8038
8111
|
const withBookingLock = async (bookingId, fn) => {
|
|
@@ -8069,7 +8142,17 @@ async function createRelayRuntime(config) {
|
|
|
8069
8142
|
active--;
|
|
8070
8143
|
}
|
|
8071
8144
|
};
|
|
8072
|
-
|
|
8145
|
+
const handleQuote = async (body, res) => {
|
|
8146
|
+
if (active >= (config.maxConcurrentRequests ?? 64)) return send(res, 503, { error: "relay_busy" });
|
|
8147
|
+
active++;
|
|
8148
|
+
try {
|
|
8149
|
+
const out = await core.quote(body.request);
|
|
8150
|
+
return send(res, out.status, out.body);
|
|
8151
|
+
} finally {
|
|
8152
|
+
active--;
|
|
8153
|
+
}
|
|
8154
|
+
};
|
|
8155
|
+
return { handleDraw, handleQuote, close: () => served.close?.() };
|
|
8073
8156
|
}
|
|
8074
8157
|
async function fetchPlatformConfig(config) {
|
|
8075
8158
|
const r = await fetch(config.apiBase + "/api/config", { signal: AbortSignal.timeout(5e3) });
|
package/package.json
CHANGED