mtok-relay 0.1.10 → 0.2.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Reference seller relay for [mtok.market](https://mtok.market). Run with:
4
4
 
5
- npx mtok-relay --offer <id> --model <id> --upstream <url>
5
+ npx mtok-relay --offer <id> --model <id> --upstream <url> --out-price <positive-usd-per-MTok>
6
6
 
7
7
 
8
8
  ---
@@ -2157,7 +2157,7 @@ var secp256k1 = createCurve({
2157
2157
  }, sha256);
2158
2158
 
2159
2159
  // node_modules/viem/_esm/errors/version.js
2160
- var version = "2.54.6";
2160
+ var version = "2.55.0";
2161
2161
 
2162
2162
  // node_modules/viem/_esm/errors/base.js
2163
2163
  var errorConfig = {
@@ -4646,15 +4646,22 @@ function readRelayConfig({ argv = process.argv.slice(2), env = process.env } = {
4646
4646
  const rpcFlag = flag(argv, "--rpc");
4647
4647
  const settlementPubkeyFlag = flag(argv, "--settlement-pubkey");
4648
4648
  const sellerAgentId = flag(argv, "--seller-agent");
4649
- const outPrice = Number(flag(argv, "--out-price") ?? 0);
4649
+ const outPrice = Number(flag(argv, "--out-price"));
4650
4650
  const inPrice = Number(flag(argv, "--in-price") ?? outPrice);
4651
4651
  const redemptionFlag = flag(argv, "--redemption-file") ?? env.RELAY_REDEMPTION_FILE;
4652
- const redemptionFile = redemptionFlag === void 0 ? "./.mtok-redemption.jsonl" : redemptionFlag || null;
4652
+ if (redemptionFlag === "") throw new Error("--redemption-file cannot be empty; paid serves require durable redemption");
4653
+ const redemptionFile = redemptionFlag === void 0 ? "./.mtok-redemption.jsonl" : redemptionFlag;
4653
4654
  const denylistRaw = flag(argv, "--payer-denylist") ?? env.RELAY_PAYER_DENYLIST ?? "";
4654
4655
  const payerDenylist = String(denylistRaw).split(",").map((a) => a.trim().toLowerCase()).filter(Boolean);
4655
4656
  if (!offerId) throw new Error("--offer <id> is required");
4656
4657
  if (!model) throw new Error("--model <id> is required (the offer model you serve)");
4657
4658
  if (!upstream) throw new Error("--upstream <url> is required");
4659
+ if (!Number.isFinite(outPrice) || outPrice <= 0) {
4660
+ throw new Error("--out-price <usd/MTok> is required and must be finite and positive");
4661
+ }
4662
+ if (!Number.isFinite(inPrice) || inPrice <= 0) {
4663
+ throw new Error("--in-price <usd/MTok> must be finite and positive");
4664
+ }
4658
4665
  const mtokApiKey = env.MTOK_API_KEY;
4659
4666
  const upstreamKey = env.UPSTREAM_KEY;
4660
4667
  const relayWalletKey = env.RELAY_WALLET_KEY;
@@ -4781,56 +4788,143 @@ function configuredFeeAtomic({ sellerUsdAtomic, feeAddress, feeBps }) {
4781
4788
  if (!feeAddress || bps === 0n) return 0n;
4782
4789
  return (BigInt(sellerUsdAtomic || 0) * bps + 5000n) / 10000n;
4783
4790
  }
4784
- var CHARS_PER_TOKEN_EST = 3.2;
4791
+ var MESSAGE_OVERHEAD_TOKENS = 4;
4785
4792
  function estimateInputTokens(messages) {
4786
- let chars = 0;
4793
+ const utf8 = new TextEncoder();
4794
+ let tokens = 3;
4787
4795
  for (const m of messages ?? []) {
4788
- const c = m?.content;
4789
- if (typeof c === "string") chars += c.length;
4790
- else if (Array.isArray(c)) for (const part of c) chars += String(part?.text ?? "").length;
4796
+ tokens += MESSAGE_OVERHEAD_TOKENS;
4797
+ tokens += utf8.encode(String(m?.role ?? "")).length;
4798
+ tokens += utf8.encode(typeof m?.content === "string" ? m.content : JSON.stringify(m?.content ?? null)).length;
4791
4799
  }
4792
- return Math.ceil(chars / CHARS_PER_TOKEN_EST);
4800
+ return tokens;
4793
4801
  }
4794
4802
  function boundServe({ messages, budgetUsd, inPrice, outPrice, reqMax, contextCeil = 4096 }) {
4795
4803
  const estIn = estimateInputTokens(messages);
4796
4804
  const estInCostUsd = estIn * (Number(inPrice) > 0 ? Number(inPrice) : 0) / 1e6;
4797
- if (estInCostUsd >= budgetUsd) return { refuse: true, estIn, estInCostUsd };
4805
+ if (estInCostUsd >= budgetUsd) return { refuse: true, reason: "input", estIn, estInCostUsd };
4798
4806
  const outBudgetUsd = budgetUsd - estInCostUsd;
4799
4807
  let maxTok = contextCeil;
4800
4808
  if (Number(reqMax) > 0) maxTok = Math.min(maxTok, Math.floor(Number(reqMax)));
4801
- if (Number(outPrice) > 0) maxTok = Math.min(maxTok, Math.floor(outBudgetUsd / Number(outPrice) * 1e6));
4802
- return { refuse: false, maxTok: Math.max(1, maxTok), estIn, estInCostUsd };
4809
+ if (!Number.isFinite(Number(outPrice)) || Number(outPrice) <= 0) {
4810
+ return { refuse: true, reason: "output_price", estIn, estInCostUsd };
4811
+ }
4812
+ maxTok = Math.min(maxTok, Math.floor(outBudgetUsd / Number(outPrice) * 1e6));
4813
+ if (maxTok < 1) return { refuse: true, reason: "output", estIn, estInCostUsd };
4814
+ return { refuse: false, maxTok, estIn, estInCostUsd };
4803
4815
  }
4804
4816
 
4805
4817
  // src/redemption.mjs
4806
4818
  import fs from "node:fs";
4819
+ import crypto2 from "node:crypto";
4807
4820
  var DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
4808
4821
  function createRedemptionStore({ file = null, retentionMs = DEFAULT_RETENTION_MS, now = () => Date.now(), log = console } = {}) {
4809
4822
  const map = /* @__PURE__ */ new Map();
4823
+ const claimsDir = file ? `${file}.claims` : null;
4810
4824
  let durable = false;
4825
+ let seenVersion = null;
4826
+ const recordFor = (key, entry) => ({
4827
+ k: key,
4828
+ at: entry.at,
4829
+ state: entry.state,
4830
+ ...entry.state === "complete" ? { payload: entry.payload } : {}
4831
+ });
4832
+ const syncWrite = (target, flags, data) => {
4833
+ const fd = fs.openSync(target, flags);
4834
+ try {
4835
+ fs.writeFileSync(fd, data);
4836
+ fs.fsyncSync(fd);
4837
+ } finally {
4838
+ fs.closeSync(fd);
4839
+ }
4840
+ };
4841
+ const readRecords = () => {
4842
+ const loaded = /* @__PURE__ */ new Map();
4843
+ if (!fs.existsSync(file)) return loaded;
4844
+ const cutoff = now() - retentionMs;
4845
+ let lineNumber = 0;
4846
+ for (const line of fs.readFileSync(file, "utf8").split("\n")) {
4847
+ lineNumber += 1;
4848
+ if (!line.trim()) continue;
4849
+ let parsed;
4850
+ try {
4851
+ parsed = JSON.parse(line);
4852
+ } catch {
4853
+ throw new Error(`malformed redemption record at line ${lineNumber}`);
4854
+ }
4855
+ const { k, at } = parsed;
4856
+ if (typeof k !== "string" || !k || !Number.isFinite(Number(at))) {
4857
+ throw new Error(`malformed redemption record at line ${lineNumber}`);
4858
+ }
4859
+ if (Number(at) < cutoff) continue;
4860
+ const state2 = parsed.state === "pending" ? "pending" : "complete";
4861
+ loaded.set(k, { state: state2, at: Number(at), ...state2 === "complete" ? { payload: parsed.payload } : {} });
4862
+ }
4863
+ return loaded;
4864
+ };
4865
+ const fileVersion = () => {
4866
+ try {
4867
+ const stat = fs.statSync(file);
4868
+ return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
4869
+ } catch {
4870
+ return null;
4871
+ }
4872
+ };
4811
4873
  if (file) {
4812
4874
  try {
4813
- if (fs.existsSync(file)) {
4814
- const cutoff = now() - retentionMs;
4815
- for (const line of fs.readFileSync(file, "utf8").split("\n")) {
4816
- if (!line.trim()) continue;
4817
- try {
4818
- const { k, at, payload } = JSON.parse(line);
4819
- if (k && Number(at) >= cutoff) map.set(k, { payload, at: Number(at) });
4820
- } catch {
4821
- }
4822
- }
4875
+ for (const [key, entry] of readRecords()) map.set(key, entry);
4876
+ const compact = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
4877
+ try {
4878
+ syncWrite(compact, "w", [...map].map(([k, e]) => JSON.stringify(recordFor(k, e))).join("\n") + (map.size ? "\n" : ""));
4879
+ fs.renameSync(compact, file);
4880
+ } finally {
4881
+ fs.rmSync(compact, { force: true });
4823
4882
  }
4824
- fs.writeFileSync(file, [...map].map(([k, e]) => JSON.stringify({ k, at: e.at, payload: e.payload })).join("\n") + (map.size ? "\n" : ""));
4883
+ fs.mkdirSync(claimsDir, { recursive: true });
4825
4884
  durable = true;
4885
+ seenVersion = fileVersion();
4826
4886
  log.log?.(`mtok-relay: durable redemption at ${file} (${map.size} entries loaded)`);
4827
4887
  } catch (e) {
4828
- log.warn?.(`mtok-relay: redemption file ${file} not writable (${e.message}); redemption is IN-MEMORY ONLY, a restart can re-serve a paid draw (#495). Point --redemption-file at a durable path.`);
4888
+ map.clear();
4889
+ log.warn?.(`mtok-relay: redemption file ${file} not writable (${e.message}); paid serves will fail closed. Point --redemption-file at a durable path.`);
4829
4890
  durable = false;
4830
4891
  }
4831
4892
  } else {
4832
- log.warn?.("mtok-relay: no --redemption-file, redemption is IN-MEMORY ONLY, a restart or long idle can re-serve one payment for a fresh inference (#495). Set --redemption-file to a durable path.");
4893
+ log.warn?.("mtok-relay: no --redemption-file; paid serves will fail closed because a claim cannot be persisted. Set --redemption-file to a durable path.");
4833
4894
  }
4895
+ const append = (key, entry) => {
4896
+ if (!durable) throw new Error("durable redemption unavailable");
4897
+ syncWrite(file, "a", JSON.stringify(recordFor(key, entry)) + "\n");
4898
+ seenVersion = fileVersion();
4899
+ };
4900
+ const markerFor = (key) => `${claimsDir}/${crypto2.createHash("sha256").update(String(key)).digest("hex")}`;
4901
+ const markClaimed = (key) => {
4902
+ if (!durable) throw new Error("durable redemption unavailable");
4903
+ const marker = markerFor(key);
4904
+ try {
4905
+ syncWrite(marker, "wx", String(key) + "\n");
4906
+ return true;
4907
+ } catch (e) {
4908
+ if (e?.code === "EEXIST") return false;
4909
+ throw e;
4910
+ }
4911
+ };
4912
+ const refresh = () => {
4913
+ if (!durable) return;
4914
+ const version2 = fileVersion();
4915
+ if (!version2 || version2 === seenVersion) return;
4916
+ try {
4917
+ for (const [key, entry] of readRecords()) map.set(key, entry);
4918
+ seenVersion = version2;
4919
+ } catch (e) {
4920
+ log.warn?.(`mtok-relay: could not refresh redemption file ${file} (${e.message}); keeping the existing fail-closed state`);
4921
+ }
4922
+ };
4923
+ const state = (key) => {
4924
+ const current = map.get(key)?.state;
4925
+ if (durable && (!current || current === "pending")) refresh();
4926
+ return map.get(key)?.state ?? null;
4927
+ };
4834
4928
  return {
4835
4929
  durable,
4836
4930
  retentionMs,
@@ -4838,18 +4932,30 @@ function createRedemptionStore({ file = null, retentionMs = DEFAULT_RETENTION_MS
4838
4932
  has(key) {
4839
4933
  return map.has(key);
4840
4934
  },
4935
+ state,
4841
4936
  get(key) {
4842
- return map.get(key)?.payload;
4937
+ return state(key) === "complete" ? map.get(key).payload : void 0;
4938
+ },
4939
+ claim(key, markerKey = key) {
4940
+ if (map.has(key)) return false;
4941
+ if (!markClaimed(markerKey)) return false;
4942
+ const entry = { state: "pending", at: now() };
4943
+ append(key, entry);
4944
+ map.set(key, entry);
4945
+ return true;
4946
+ },
4947
+ complete(key, payload) {
4948
+ if (map.get(key)?.state !== "pending") throw new Error("redemption is not pending");
4949
+ const entry = { state: "complete", payload, at: now() };
4950
+ append(key, entry);
4951
+ map.set(key, entry);
4843
4952
  },
4953
+ // Backward-compatible store API for callers which only persist completed
4954
+ // payloads. The relay runtime itself always uses claim() then complete().
4844
4955
  set(key, payload) {
4845
- const at = now();
4846
- map.set(key, { payload, at });
4847
- if (durable) {
4848
- try {
4849
- fs.appendFileSync(file, JSON.stringify({ k: key, at, payload }) + "\n");
4850
- } catch {
4851
- }
4852
- }
4956
+ const entry = { state: "complete", payload, at: now() };
4957
+ if (durable) append(key, entry);
4958
+ map.set(key, entry);
4853
4959
  }
4854
4960
  };
4855
4961
  }
@@ -5162,11 +5268,85 @@ var BASE_SEPOLIA_RPCS = [
5162
5268
  var rpcUrlsFor = (chainId, override) => override ? [override] : Number(chainId) === 8453 ? BASE_MAINNET_RPCS : BASE_SEPOLIA_RPCS;
5163
5269
 
5164
5270
  // src/runtime.mjs
5165
- import crypto2 from "node:crypto";
5271
+ import crypto3 from "node:crypto";
5166
5272
  var BALANCE_EPSILON = 1e-6;
5167
- var hash32 = (v) => "0x" + crypto2.createHash("sha256").update(typeof v === "string" ? v : JSON.stringify(v ?? null)).digest("hex");
5273
+ var hash32 = (v) => "0x" + crypto3.createHash("sha256").update(typeof v === "string" ? v : JSON.stringify(v ?? null)).digest("hex");
5274
+ var REQUEST_NONCE_RE = /^0x[0-9a-fA-F]{32}$/;
5275
+ var CHAT_ROLES = /* @__PURE__ */ new Set(["developer", "system", "user", "assistant"]);
5276
+ var REQUEST_KEYS = /* @__PURE__ */ new Set(["model", "messages", "max_tokens", "temperature", "response_format", "stream", "n"]);
5277
+ function legacyContentText(content) {
5278
+ const render = (part) => {
5279
+ if (typeof part === "string") return part;
5280
+ if (Array.isArray(part)) return part.map(render).filter(Boolean).join("\n");
5281
+ if (part == null) return "";
5282
+ if (typeof part !== "object") return String(part);
5283
+ if (typeof part.text === "string") return part.text;
5284
+ if (typeof part.content === "string") return part.content;
5285
+ const type = typeof part.type === "string" && /^[a-z0-9_-]{1,32}$/i.test(part.type) ? part.type : "non-text";
5286
+ return `[${type} omitted]`;
5287
+ };
5288
+ return render(content) || "[empty legacy content]";
5289
+ }
5290
+ function sanitizeLegacyMessage(message) {
5291
+ const source = message && typeof message === "object" && !Array.isArray(message) ? message : { content: message };
5292
+ if (CHAT_ROLES.has(source.role)) return { role: source.role, content: legacyContentText(source.content) };
5293
+ const label = source.role === "tool" || source.role === "function" ? source.role : "legacy";
5294
+ return { role: "user", content: `[${label} message]
5295
+ ${legacyContentText(source.content)}` };
5296
+ }
5297
+ function validateRequest(request, model, { legacy = false } = {}) {
5298
+ if (!request || typeof request !== "object" || Array.isArray(request)) {
5299
+ return { error: "request must be an object" };
5300
+ }
5301
+ const unknown = Object.keys(request).find((key) => !REQUEST_KEYS.has(key));
5302
+ if (unknown && !legacy) return { error: `unsupported request field: ${unknown}` };
5303
+ if (!legacy && request.model != null && String(request.model) !== String(model)) {
5304
+ return { error: `request model ${request.model} is not served here` };
5305
+ }
5306
+ if (!Array.isArray(request.messages) || request.messages.length === 0) {
5307
+ return { error: "request.messages must be a nonempty array" };
5308
+ }
5309
+ if (!legacy) {
5310
+ for (const message of request.messages) {
5311
+ if (!message || typeof message !== "object" || Array.isArray(message)) {
5312
+ return { error: "each message must be an object" };
5313
+ }
5314
+ const extra = Object.keys(message).find((key) => key !== "role" && key !== "content");
5315
+ if (extra) return { error: `unsupported message field: ${extra}` };
5316
+ if (!CHAT_ROLES.has(message.role)) return { error: `unsupported message role: ${message.role}` };
5317
+ if (typeof message.content !== "string") return { error: "message content must be plain text" };
5318
+ }
5319
+ }
5320
+ if (!legacy && request.stream != null && request.stream !== false) return { error: "streaming is not supported" };
5321
+ if (!legacy && request.n != null && request.n !== 1) return { error: "request.n must be 1" };
5322
+ const validMaxTokens = Number.isInteger(request.max_tokens) && request.max_tokens > 0;
5323
+ if (!legacy && request.max_tokens != null && !validMaxTokens) {
5324
+ return { error: "max_tokens must be a positive integer" };
5325
+ }
5326
+ const validTemperature = Number.isFinite(request.temperature) && request.temperature >= 0 && request.temperature <= 2;
5327
+ if (!legacy && request.temperature != null && !validTemperature) {
5328
+ return { error: "temperature must be between 0 and 2" };
5329
+ }
5330
+ let validResponseFormat = false;
5331
+ if (request.response_format != null) {
5332
+ const format = request.response_format;
5333
+ validResponseFormat = !!format && typeof format === "object" && !Array.isArray(format) && Object.keys(format).length === 1 && ["json_object", "text"].includes(format.type);
5334
+ if (!legacy && !validResponseFormat) {
5335
+ return { error: 'response_format must be exactly { type: "json_object" } or { type: "text" }' };
5336
+ }
5337
+ }
5338
+ return {
5339
+ safeRequest: {
5340
+ model,
5341
+ messages: legacy ? request.messages.map(sanitizeLegacyMessage) : request.messages.map(({ role, content }) => ({ role, content })),
5342
+ ...validMaxTokens ? { max_tokens: request.max_tokens } : {},
5343
+ ...validTemperature ? { temperature: request.temperature } : {},
5344
+ ...validResponseFormat ? { response_format: { type: request.response_format.type } } : {}
5345
+ }
5346
+ };
5347
+ }
5168
5348
  async function createRelayRuntime(config) {
5169
- const served = createRedemptionStore({ file: config.redemptionFile });
5349
+ const served = createRedemptionStore({ file: config.redemptionFile, log: config.log });
5170
5350
  const drawLocks = /* @__PURE__ */ new Map();
5171
5351
  const platform = await fetchPlatformConfig(config);
5172
5352
  const verifier = createOnchainVerifier({ rpcUrls: rpcUrlsFor(platform.chainId, config.rpcFlag), usdcAddress: platform.usdcAddress, expectedChainId: platform.chainId });
@@ -5199,12 +5379,23 @@ async function createRelayRuntime(config) {
5199
5379
  }
5200
5380
  };
5201
5381
  const handleDraw = async (body, res) => {
5202
- const { bookingId, n, buyerId, request, drawPaidTxHash } = body;
5382
+ const { bookingId, n, buyerId, request, requestNonce, drawPaidTxHash } = body;
5383
+ const hasRequestNonce = Object.hasOwn(body, "requestNonce");
5203
5384
  if (!bookingId) return send(res, 400, { error: "bad_request", detail: "DRAW needs bookingId" });
5204
5385
  if (n == null) return send(res, 400, { error: "bad_request", detail: "DRAW needs a delivery index n (per-booking idempotency key)" });
5386
+ if (!Number.isSafeInteger(n) || n < 0 || n > 4294967295) {
5387
+ return send(res, 400, { error: "bad_request", detail: "DRAW delivery index n must be a nonnegative uint32 integer" });
5388
+ }
5389
+ if (hasRequestNonce && !REQUEST_NONCE_RE.test(requestNonce)) {
5390
+ return send(res, 400, { error: "bad_request", detail: "DRAW needs requestNonce as 16 random bytes encoded as 0x-prefixed hex" });
5391
+ }
5392
+ const checked = validateRequest(request, config.model, { legacy: !hasRequestNonce });
5393
+ if (checked.error) return send(res, 400, { error: "bad_request", detail: checked.error });
5205
5394
  return withBookingLock(bookingId, async () => {
5206
- const requestHash = hash32(request);
5207
- const cacheKey = `${bookingId}:${n}:${requestHash}`;
5395
+ const requestHashScheme = hasRequestNonce ? "nonce-v1" : "legacy-v0";
5396
+ const requestHash = hasRequestNonce ? hash32({ request, requestNonce }) : hash32(request);
5397
+ const cacheKey = `${requestHashScheme}:${bookingId}:${n}:${requestHash}`;
5398
+ const oldLegacyKey = hasRequestNonce ? null : `${bookingId}:${n}:${requestHash}`;
5208
5399
  if (!platform.dripContractAddress) {
5209
5400
  return send(res, 402, { error: "contract_mode_required", detail: "this relay only serves contract-mode draws; the platform is not reporting a dripContractAddress" });
5210
5401
  }
@@ -5223,10 +5414,9 @@ async function createRelayRuntime(config) {
5223
5414
  requestHash,
5224
5415
  sellerWallet: config.settlementAddr,
5225
5416
  feeRecipient: platform.feeAddress,
5226
- // #580: refuse a payment older than the redemption window. served.has() is the
5227
- // primary one-serve guard, but it's pruned by age and lost on an in-memory restart;
5228
- // this on-chain age bound closes the re-serve hole those cases open (a stale replay
5229
- // buying a fresh inference). An honest retry is seconds-to-minutes old, never days.
5417
+ // #580: refuse a payment older than the redemption window. The JSONL
5418
+ // payload cache is compacted by age (exclusive claim markers remain
5419
+ // fail-closed), and an honest retry is seconds-to-minutes old, never days.
5230
5420
  maxPaidAgeMs: served.retentionMs
5231
5421
  });
5232
5422
  } catch (e) {
@@ -5248,19 +5438,43 @@ async function createRelayRuntime(config) {
5248
5438
  } catch (e) {
5249
5439
  return send(res, 403, { error: "payer_denied", detail: "payer screening failed: " + e.message });
5250
5440
  }
5251
- if (served.has(cacheKey)) return send(res, 200, served.get(cacheKey));
5441
+ let storedKey = cacheKey;
5442
+ let redemptionState = served.state(storedKey);
5443
+ if (!redemptionState && oldLegacyKey) {
5444
+ const oldLegacyState = served.state(oldLegacyKey);
5445
+ if (oldLegacyState) {
5446
+ storedKey = oldLegacyKey;
5447
+ redemptionState = oldLegacyState;
5448
+ } else {
5449
+ redemptionState = served.state(cacheKey);
5450
+ }
5451
+ }
5452
+ if (redemptionState === "complete") return send(res, 200, served.get(storedKey));
5453
+ if (redemptionState === "pending") {
5454
+ return send(res, 409, { error: "draw_pending", detail: "this paid draw was already claimed; refusing to run upstream again", _bookingId: bookingId });
5455
+ }
5252
5456
  const paidEvent = paid.event;
5253
5457
  const remainingUsd = Number(paid.event.sellerUsdAtomic || 0) / 1e6;
5254
- if (remainingUsd <= BALANCE_EPSILON) {
5458
+ if (remainingUsd < BALANCE_EPSILON) {
5255
5459
  return send(res, 402, { error: "balance_exhausted", detail: `remainingUsd=${remainingUsd}`, _bookingId: bookingId, remainingUsd });
5256
5460
  }
5257
5461
  const eventInPriceUsd = Number(paidEvent.inputPricePerMTokAtomic || 0) / 1e6;
5462
+ const eventOutPriceUsd = Number(paidEvent.outputPricePerMTokAtomic || 0) / 1e6;
5258
5463
  const inPrice = Math.max(Number(config.inPrice) || 0, eventInPriceUsd);
5259
- const bound = boundServe({ messages: request?.messages, budgetUsd: remainingUsd, inPrice, outPrice: config.outPrice, reqMax: Number(request?.max_tokens) });
5464
+ const outPrice = Math.max(Number(config.outPrice) || 0, eventOutPriceUsd);
5465
+ const bound = boundServe({ messages: checked.safeRequest.messages, budgetUsd: remainingUsd, inPrice, outPrice, reqMax: checked.safeRequest.max_tokens });
5260
5466
  if (bound.refuse) {
5261
- return send(res, 402, { error: "input_too_large", detail: `estimated input (~${bound.estIn} tokens, $${bound.estInCostUsd.toFixed(6)}) meets or exceeds the paid amount ($${remainingUsd}); send a shorter prompt or pay for more`, _bookingId: bookingId, remainingUsd });
5467
+ const error = bound.reason === "input" ? "input_too_large" : "output_unfunded";
5468
+ return send(res, 402, { error, detail: `estimated input (~${bound.estIn} tokens, $${bound.estInCostUsd.toFixed(6)}) leaves no safely funded output in the paid amount ($${remainingUsd})`, _bookingId: bookingId, remainingUsd });
5469
+ }
5470
+ const safeRequest = { ...checked.safeRequest, model: config.model, max_tokens: bound.maxTok };
5471
+ try {
5472
+ if (!served.claim(cacheKey, oldLegacyKey ?? cacheKey)) {
5473
+ return send(res, 409, { error: "draw_pending", detail: "this paid draw was already claimed; refusing to run upstream again", _bookingId: bookingId });
5474
+ }
5475
+ } catch (e) {
5476
+ return send(res, 503, { error: "redemption_unavailable", detail: `could not durably claim the paid draw: ${e.message}`, _bookingId: bookingId });
5262
5477
  }
5263
- const safeRequest = { ...request, max_tokens: bound.maxTok };
5264
5478
  let completion;
5265
5479
  try {
5266
5480
  completion = await upstream(safeRequest);
@@ -5277,7 +5491,11 @@ async function createRelayRuntime(config) {
5277
5491
  const outTok = Number(usage.completion_tokens ?? usage.output_tokens ?? 0) || 0;
5278
5492
  const usedUsd = (inTok * Number(paidEvent.inputPricePerMTokAtomic || 0) + outTok * Number(paidEvent.outputPricePerMTokAtomic || 0)) / 1e12;
5279
5493
  const payload = { ...completion, _bookingId: bookingId, remainingUsd: Math.max(0, Math.round((remainingUsd - usedUsd) * 1e6) / 1e6) };
5280
- served.set(cacheKey, payload);
5494
+ try {
5495
+ served.complete(cacheKey, payload);
5496
+ } catch (e) {
5497
+ (config.log ?? console).error?.(`mtok-relay: completion for ${cacheKey} could not be persisted (${e.message}); retries will remain pending`);
5498
+ }
5281
5499
  return send(res, 200, payload);
5282
5500
  });
5283
5501
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mtok-relay",
3
- "version": "0.1.10",
4
- "description": "Reference seller relay for mtok.market — accepts on-chain-prepaid chunk draws and serves inference from an upstream you control. Run with: npx mtok-relay --offer <id> --model <id> --upstream <url>.",
3
+ "version": "0.2.0",
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": {
7
7
  "mtok-relay": "dist/mtok-relay.mjs"