mtok-relay 0.2.8 → 0.3.1

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,44 @@ function createServeCore({
7798
7804
  feeRecipient,
7799
7805
  feeBps,
7800
7806
  screenPayer,
7807
+ // A retired payment domain may honor verified receipts without admitting
7808
+ // quotes or another inference attempt for an unrecorded payment.
7809
+ replayOnly = false,
7810
+ // The host counts its actual sanitized provider input. Missing or failed
7811
+ // accounting refuses a fresh claim; saved completions do not need recounting.
7812
+ countInputTokens,
7813
+ maxInputTokens = DEFAULT_MAX_INPUT_TOKENS,
7801
7814
  // #654: the output-token sanity ceiling for boundServe. The PAID budget already
7802
7815
  // bounds output (and metering is on real usage), so this is a defensive cap on a
7803
7816
  // single generation, not a money guard. It is a per-relay knob: the reference host
7804
7817
  // passes MTOK_MAX_OUTPUT_TOKENS / a config value; operators serving large-context
7805
7818
  // models raise it. Falls back to boundServe's own generous default when unset.
7806
- maxOutputTokens
7819
+ maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS
7807
7820
  }) {
7821
+ const quote = async (request) => {
7822
+ if (replayOnly) return { status: 503, body: { error: "redemption_unavailable", detail: "this relay only replays saved completions" } };
7823
+ const checked = validateRequest(request, model);
7824
+ if (checked.error) return { status: 400, body: { error: "bad_request", detail: checked.error } };
7825
+ try {
7826
+ const inputTokens = await countInputTokens(checked.safeRequest);
7827
+ if (!withinInputLimit(inputTokens, maxInputTokens)) return { status: 413, body: { error: "input_too_large", detail: `input (${inputTokens} tokens) exceeds this relay's input-token limit` } };
7828
+ if (!Number.isSafeInteger(maxOutputTokens) || maxOutputTokens <= 0) throw new TypeError("output token ceiling must be a positive safe integer");
7829
+ const inputPrice = offerPriceAtomic(inPrice);
7830
+ const outputPrice = offerPriceAtomic(outPrice);
7831
+ if (inputPrice < 0n || outputPrice <= 0n) throw new RangeError("invalid offer prices");
7832
+ return { status: 200, body: {
7833
+ model,
7834
+ offerId,
7835
+ inputTokens,
7836
+ maxInputTokens,
7837
+ maxOutputTokens,
7838
+ inputPricePerMTokAtomic: inputPrice.toString(),
7839
+ outputPricePerMTokAtomic: outputPrice.toString()
7840
+ } };
7841
+ } catch (e) {
7842
+ return { status: 503, body: { error: "input_accounting_unavailable", detail: e.message } };
7843
+ }
7844
+ };
7808
7845
  const serve = async (body) => {
7809
7846
  const currentFeeRecipient = typeof feeRecipient === "function" ? feeRecipient() : feeRecipient;
7810
7847
  const { bookingId, n, buyerId, request, requestNonce, drawPaidTxHash } = body;
@@ -7880,6 +7917,9 @@ function createServeCore({
7880
7917
  if (redemptionState === "pending") {
7881
7918
  return { status: 409, body: { error: "draw_pending", detail: "this paid draw was already claimed; refusing to run upstream again", _bookingId: bookingId } };
7882
7919
  }
7920
+ if (replayOnly) {
7921
+ return { status: 503, body: { error: "redemption_unavailable", detail: "this payment has no saved completion; retry this same paid draw after reconciliation", _bookingId: bookingId } };
7922
+ }
7883
7923
  let currentFeeBps;
7884
7924
  try {
7885
7925
  currentFeeBps = typeof feeBps === "function" ? await feeBps(paid) : feeBps ?? 0;
@@ -7896,14 +7936,30 @@ function createServeCore({
7896
7936
  if (remainingUsd < BALANCE_EPSILON) {
7897
7937
  return { status: 402, body: { error: "balance_exhausted", detail: `remainingUsd=${remainingUsd}`, _bookingId: bookingId, remainingUsd } };
7898
7938
  }
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)) } : {} });
7939
+ let bound;
7940
+ try {
7941
+ const inputTokens = await countInputTokens(checked.safeRequest);
7942
+ const price = (offered, committed) => {
7943
+ const local = offerPriceAtomic(offered);
7944
+ const paid2 = BigInt(committed);
7945
+ return local > paid2 ? local : paid2;
7946
+ };
7947
+ bound = boundServe({
7948
+ inputTokens,
7949
+ budgetUsdAtomic: paidEvent.sellerUsdAtomic,
7950
+ inPriceAtomic: price(inPrice, paidEvent.inputPricePerMTokAtomic),
7951
+ outPriceAtomic: price(outPrice, paidEvent.outputPricePerMTokAtomic),
7952
+ reqMax: checked.safeRequest.max_tokens,
7953
+ inputCeil: maxInputTokens,
7954
+ contextCeil: maxOutputTokens
7955
+ });
7956
+ } catch (e) {
7957
+ return { status: 503, body: { error: "input_accounting_unavailable", detail: e.message, _bookingId: bookingId } };
7958
+ }
7904
7959
  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 } };
7960
+ const error = bound.reason === "input" || bound.reason === "input_limit" ? "input_too_large" : "output_unfunded";
7961
+ 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})`;
7962
+ return { status: 402, body: { error, detail, _bookingId: bookingId, remainingUsd } };
7907
7963
  }
7908
7964
  const safeRequest = { ...checked.safeRequest, model, max_tokens: bound.maxTok };
7909
7965
  try {
@@ -7938,7 +7994,7 @@ function createServeCore({
7938
7994
  }
7939
7995
  return { status: 200, body: payload };
7940
7996
  };
7941
- return { serve };
7997
+ return { serve, quote };
7942
7998
  }
7943
7999
 
7944
8000
  // bridge/bridge.mjs
@@ -7965,6 +8021,27 @@ function httpUpstream({ baseUrl, key, timeoutMs }) {
7965
8021
  return json;
7966
8022
  };
7967
8023
  }
8024
+ function httpInputCounter({ url, key, timeoutMs = 5e3 }) {
8025
+ return async ({ model, messages: messages2 }) => {
8026
+ const response = await fetch(url, {
8027
+ method: "POST",
8028
+ headers: { "content-type": "application/json", ...key ? { authorization: `Bearer ${key}` } : {} },
8029
+ body: JSON.stringify({ model, messages: messages2, add_generation_prompt: true, add_special_tokens: false }),
8030
+ signal: AbortSignal.timeout(timeoutMs)
8031
+ });
8032
+ if (!response.ok) throw new Error(`upstream tokenization ${response.status}`);
8033
+ const decoder = new TextDecoder();
8034
+ const chunks = [];
8035
+ let size3 = 0;
8036
+ for await (const chunk of response.body) {
8037
+ size3 += chunk.byteLength;
8038
+ if (size3 > 4 * 1024 * 1024) throw new Error("upstream tokenization response is too large");
8039
+ chunks.push(decoder.decode(chunk, { stream: true }));
8040
+ }
8041
+ chunks.push(decoder.decode());
8042
+ return JSON.parse(chunks.join("")).count;
8043
+ };
8044
+ }
7968
8045
 
7969
8046
  // src/rpc.mjs
7970
8047
  var BASE_MAINNET_RPCS = [
@@ -8008,6 +8085,7 @@ async function createRelayRuntime(config) {
8008
8085
  const payerDenylist = new Set((config.payerDenylist ?? []).map((a) => String(a).trim().toLowerCase()).filter(Boolean));
8009
8086
  const screenPayer = typeof config.screenPayer === "function" ? config.screenPayer : null;
8010
8087
  const upstream = httpUpstream({ baseUrl: config.upstream + "/v1", key: config.upstreamKey, timeoutMs: config.upstreamTimeoutMs ?? 12e4 });
8088
+ const countInputTokens = config.countInputTokens ?? httpInputCounter({ url: config.upstream + "/tokenize", key: config.upstreamKey });
8011
8089
  const payerDenied = async (payer) => {
8012
8090
  if (!payer) return false;
8013
8091
  if (payerDenylist.has(payer)) return true;
@@ -8033,6 +8111,8 @@ async function createRelayRuntime(config) {
8033
8111
  },
8034
8112
  // #654: per-relay output sanity ceiling (unset => the shared generous default).
8035
8113
  maxOutputTokens: config.maxOutputTokens,
8114
+ maxInputTokens: config.maxInputTokens,
8115
+ countInputTokens,
8036
8116
  screenPayer: payerDenied
8037
8117
  });
8038
8118
  const withBookingLock = async (bookingId, fn) => {
@@ -8069,7 +8149,17 @@ async function createRelayRuntime(config) {
8069
8149
  active--;
8070
8150
  }
8071
8151
  };
8072
- return { handleDraw, close: () => served.close?.() };
8152
+ const handleQuote = async (body, res) => {
8153
+ if (active >= (config.maxConcurrentRequests ?? 64)) return send(res, 503, { error: "relay_busy" });
8154
+ active++;
8155
+ try {
8156
+ const out = await core.quote(body.request);
8157
+ return send(res, out.status, out.body);
8158
+ } finally {
8159
+ active--;
8160
+ }
8161
+ };
8162
+ return { handleDraw, handleQuote, close: () => served.close?.() };
8073
8163
  }
8074
8164
  async function fetchPlatformConfig(config) {
8075
8165
  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.1",
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": {