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 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.
@@ -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.isFinite(maxOutputTokens) || maxOutputTokens < 1)) {
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" || req.url !== "/chunk") {
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
- function boundServe({ messages: messages2, budgetUsd, inPrice, outPrice, reqMax, contextCeil = DEFAULT_MAX_OUTPUT_TOKENS }) {
7773
- const estIn = estimateInputTokens(messages2);
7774
- const estInCostUsd = estIn * (Number(inPrice) > 0 ? Number(inPrice) : 0) / 1e6;
7775
- if (estInCostUsd >= budgetUsd) return { refuse: true, reason: "input", estIn, estInCostUsd };
7776
- const outBudgetUsd = budgetUsd - estInCostUsd;
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
- if (!Number.isFinite(Number(outPrice)) || Number(outPrice) <= 0) {
7780
- return { refuse: true, reason: "output_price", estIn, estInCostUsd };
7781
- }
7782
- maxTok = Math.min(maxTok, Math.floor(outBudgetUsd / Number(outPrice) * 1e6));
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
- const eventInPriceUsd = Number(paidEvent.inputPricePerMTokAtomic || 0) / 1e6;
7900
- const eventOutPriceUsd = Number(paidEvent.outputPricePerMTokAtomic || 0) / 1e6;
7901
- const boundInPrice = Math.max(Number(inPrice) || 0, eventInPriceUsd);
7902
- const boundOutPrice = Math.max(Number(outPrice) || 0, eventOutPriceUsd);
7903
- const bound = boundServe({ messages: checked.safeRequest.messages, budgetUsd: remainingUsd, inPrice: boundInPrice, outPrice: boundOutPrice, reqMax: checked.safeRequest.max_tokens, ...Number(maxOutputTokens) > 0 ? { contextCeil: Math.floor(Number(maxOutputTokens)) } : {} });
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
- return { status: 402, body: { error, detail: `estimated input (~${bound.estIn} tokens, $${bound.estInCostUsd.toFixed(6)}) leaves no safely funded output in the paid amount ($${remainingUsd})`, _bookingId: bookingId, remainingUsd } };
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
- return { handleDraw, close: () => served.close?.() };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtok-relay",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
4
4
  "description": "Reference seller relay for mtok.market — accepts on-chain-prepaid chunk draws and serves inference from an upstream you control.",
5
5
  "type": "module",
6
6
  "bin": {