mtok-bridge 0.3.5 → 0.4.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
@@ -53,6 +53,38 @@ the verifier's payment timestamp, so a store migrating old claims can refuse amb
53
53
  pre-cutover payments. Existing stores may ignore these additional arguments. The core still
54
54
  verifies the payment before reading a completion, and known claims never run upstream again.
55
55
 
56
+ Set `replayOnly: true` when retiring a payment domain. The core still verifies the
57
+ payment before replaying a saved completion or returning its pending status. An
58
+ unrecorded payment returns 503 before fee evaluation, input counting, claiming or
59
+ inference. Quotes also return 503. Keep the legacy payment verifier and store
60
+ available for these retries; this mode never admits new paid work.
61
+
62
+ The new core requires `countInputTokens(safeRequest)`, returning a nonnegative safe integer
63
+ or a promise for one. The host must count its actual provider prompt, including its chat
64
+ template and special tokens. A missing, failed or invalid counter returns 503 before claiming
65
+ a fresh draw. Completed and pending retries retain their existing outcomes without counting
66
+ again. The old byte-estimate export is retained for compatibility; neither the core nor
67
+ the current SDK uses it to authorize spending.
68
+
69
+ `core.quote(request)` validates the same request and returns `{ status, body }` with
70
+ `model`, `offerId`, `inputTokens`, `maxInputTokens`, `maxOutputTokens`, and the decimal-string
71
+ prices `inputPricePerMTokAtomic` / `outputPricePerMTokAtomic`. It performs no payment
72
+ verification, claim or inference. Expose it at `POST /quote` with `{ request }` under the
73
+ same request-size, concurrency and rate limits as `/chunk`. The paid path recounts before
74
+ claiming; a quote does not override payment verification or lock provider configuration.
75
+
76
+ For a vLLM-compatible provider, `httpInputCounter({ url, key })` calls its native `/tokenize`
77
+ chat endpoint with the same model and messages, `add_generation_prompt:true`, and
78
+ `add_special_tokens:false`. The provider must render exactly the chat template used by
79
+ inference. Unsupported providers need their own counter; there is no character-estimate
80
+ fallback. The tokenization request has a five-second deadline and a bounded response body.
81
+
82
+ `maxInputTokens` sets the hard input ceiling (default 131072). `maxOutputTokens` retains its
83
+ 32768 default. `boundServe` now takes `inputTokens`, `budgetUsdAtomic`, `inPriceAtomic` and
84
+ `outPriceAtomic`. Prices are atomic USD per million tokens. It reserves input in integer
85
+ arithmetic and funds output only from the remainder. The core uses the higher of the host's
86
+ price and the verified payment's committed price for each leg.
87
+
56
88
 
57
89
  ---
58
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtok-bridge",
3
- "version": "0.3.5",
3
+ "version": "0.4.1",
4
4
  "description": "Serve any model as an OpenAI-compatible API with a key. No payment, no market, runs anywhere node runs. The transport core behind mtok.market's seller relay.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/bridge.mjs CHANGED
@@ -75,6 +75,33 @@ export function httpUpstream({ baseUrl, key, timeoutMs }) {
75
75
  };
76
76
  }
77
77
 
78
+ // The provider owns the chat template. Its tokenization endpoint must use the
79
+ // same model and renderer as /chat/completions; a character estimate cannot
80
+ // replace this call. This is the vLLM TokenizeChatRequest contract.
81
+ export function httpInputCounter({ url, key, timeoutMs = 5000 }) {
82
+ return async ({ model, messages }) => {
83
+ const response = await fetch(url, {
84
+ method: 'POST',
85
+ headers: { 'content-type': 'application/json', ...(key ? { authorization: `Bearer ${key}` } : {}) },
86
+ body: JSON.stringify({ model, messages, add_generation_prompt: true, add_special_tokens: false }),
87
+ signal: AbortSignal.timeout(timeoutMs),
88
+ });
89
+ if (!response.ok) throw new Error(`upstream tokenization ${response.status}`);
90
+ // Native tokenizers also return every token ID. Bound that response before
91
+ // parsing, while allowing a full token array for the relay's 256 KB input.
92
+ const decoder = new TextDecoder();
93
+ const chunks = [];
94
+ let size = 0;
95
+ for await (const chunk of response.body) {
96
+ size += chunk.byteLength;
97
+ if (size > 4 * 1024 * 1024) throw new Error('upstream tokenization response is too large');
98
+ chunks.push(decoder.decode(chunk, { stream: true }));
99
+ }
100
+ chunks.push(decoder.decode());
101
+ return JSON.parse(chunks.join('')).count;
102
+ };
103
+ }
104
+
78
105
  // The SECOND upstream mode (#566): a Cloudflare Workers AI binding instead of an HTTP endpoint.
79
106
  // `ai` is the Worker's `env.AI` (has `.run(model, { messages, max_tokens })`). Returns the same
80
107
  // upstream(payload) contract as httpUpstream, normalizing Workers AI's output (native `{ response,
@@ -161,24 +161,8 @@ export function configuredFeeAtomic({ sellerUsdAtomic, feeAddress, feeBps }) {
161
161
  return (BigInt(sellerUsdAtomic || 0) * bps + 5000n) / 10000n;
162
162
  }
163
163
 
164
- // Tokenizer-independent input estimate, byte-aware with a safety margin.
165
- //
166
- // #626: this used to count one token per UTF-8 byte, i.e. a true worst-case
167
- // bound (a tokenizer cannot emit more text tokens than bytes). That bound is
168
- // correct and roughly 4x too pessimistic for real text, and the over-estimate
169
- // is NOT free: boundServe refuses a draw whose estimated input cost alone meets
170
- // the payment, and that refusal happens AFTER the buyer has paid on chain. A
171
- // real buyer sending a ~4KB prompt on a budget that comfortably covered it was
172
- // refused every night for two weeks and auto-disputed, silently.
173
- //
174
- // So estimate realistically and keep the margin explicit. BYTES_PER_TOKEN_EST
175
- // of 3.2 is the English average (~4 bytes/token) with ~25% headroom, and
176
- // staying in BYTES rather than characters keeps multibyte prompts from reading
177
- // artificially cheap. The seller's residual exposure when an estimate lands
178
- // low is bounded: actual usage is metered from the upstream response after the
179
- // serve, and the output cap is computed from whatever budget the input
180
- // estimate left, so an under-estimate eats into output headroom rather than
181
- // running unpriced.
164
+ // Legacy buyer estimate. It is not an upper bound and must never authorize
165
+ // inference: digit-dense prompts can consume more than three times this count.
182
166
  export const MESSAGE_OVERHEAD_TOKENS = 4;
183
167
  export const BYTES_PER_TOKEN_EST = 3.2;
184
168
  export function estimateInputTokens(messages) {
@@ -193,33 +177,37 @@ export function estimateInputTokens(messages) {
193
177
  return envelope + Math.ceil(bytes / BYTES_PER_TOKEN_EST);
194
178
  }
195
179
 
196
- // Bound a serve against the paid budget in BOTH legs (#495/#460). The relay used
197
- // to cap only OUTPUT, so a dust draw + a huge prompt got its output capped but the
198
- // whole prompt forwarded, making the seller eat unbounded upstream INPUT compute.
199
- // Estimate the input cost and REFUSE before any upstream call if it alone meets or
200
- // exceeds the payment; otherwise cap output over the budget LEFT after input. inPrice
201
- // and outPrice are USD per MTok. The estimate gates the refuse ONLY; real billing
202
- // still meters the upstream's reported token counts, so this never over-charges.
203
- // #654: DEFAULT_MAX_OUTPUT_TOKENS is a generous sanity ceiling, not a money guard
204
- // (the paid budget below already bounds output, and billing meters real usage). The
205
- // old 4096 was low enough to cap honest large-output requests below what the buyer
206
- // funded, so they overpaid. A relay operator can raise or lower it per their upstream
207
- // (see createServeCore's maxOutputTokens); this default just keeps a no-max_tokens
208
- // request on a big budget from triggering one runaway generation.
180
+ // Reserve the counted input before output. Prices are atomic USD per million
181
+ // tokens; keeping that denominator until division avoids rounding a fractional
182
+ // input charge away or spending one atomic unit twice. Billing still uses usage.
209
183
  export const DEFAULT_MAX_OUTPUT_TOKENS = 32768;
210
- export function boundServe({ messages, budgetUsd, inPrice, outPrice, reqMax, contextCeil = DEFAULT_MAX_OUTPUT_TOKENS }) {
211
- const estIn = estimateInputTokens(messages);
212
- const estInCostUsd = estIn * (Number(inPrice) > 0 ? Number(inPrice) : 0) / 1e6;
213
- if (estInCostUsd >= budgetUsd) return { refuse: true, reason: 'input', estIn, estInCostUsd };
214
- const outBudgetUsd = budgetUsd - estInCostUsd;
184
+ export const DEFAULT_MAX_INPUT_TOKENS = 131072;
185
+ function withinInputLimit(inputTokens, inputCeil) {
186
+ if (!Number.isSafeInteger(inputTokens) || inputTokens < 0) throw new TypeError('input token count must be a nonnegative safe integer');
187
+ if (!Number.isSafeInteger(inputCeil) || inputCeil <= 0) throw new TypeError('input token ceiling must be a positive safe integer');
188
+ return inputTokens <= inputCeil;
189
+ }
190
+ const offerPriceAtomic = value => BigInt(Math.ceil(Number(value) * 1e6));
191
+
192
+ export function boundServe({ inputTokens, budgetUsdAtomic, inPriceAtomic, outPriceAtomic, reqMax, inputCeil = DEFAULT_MAX_INPUT_TOKENS, contextCeil = DEFAULT_MAX_OUTPUT_TOKENS }) {
193
+ const inputAllowed = withinInputLimit(inputTokens, inputCeil);
194
+ if (!Number.isSafeInteger(contextCeil) || contextCeil <= 0) throw new TypeError('output token ceiling must be a positive safe integer');
195
+ const budget = BigInt(budgetUsdAtomic);
196
+ const inputPrice = BigInt(inPriceAtomic);
197
+ const outputPrice = BigInt(outPriceAtomic);
198
+ if (budget < 0n || inputPrice < 0n || outputPrice < 0n) throw new RangeError('token budget and prices must be nonnegative');
199
+ const inputCost = BigInt(inputTokens) * inputPrice;
200
+ const details = { inputTokens, inputCostUsd: Number(inputCost) / 1e12 };
201
+ if (!inputAllowed) return { refuse: true, reason: 'input_limit', ...details };
202
+ const outputBudget = budget * 1_000_000n - inputCost;
203
+ if (outputBudget <= 0n) return { refuse: true, reason: 'input', ...details };
204
+ if (outputPrice === 0n) return { refuse: true, reason: 'output_price', ...details };
215
205
  let maxTok = contextCeil;
216
206
  if (Number(reqMax) > 0) maxTok = Math.min(maxTok, Math.floor(Number(reqMax)));
217
- if (!Number.isFinite(Number(outPrice)) || Number(outPrice) <= 0) {
218
- return { refuse: true, reason: 'output_price', estIn, estInCostUsd };
219
- }
220
- maxTok = Math.min(maxTok, Math.floor(outBudgetUsd / Number(outPrice) * 1e6));
221
- if (maxTok < 1) return { refuse: true, reason: 'output', estIn, estInCostUsd };
222
- return { refuse: false, maxTok, estIn, estInCostUsd };
207
+ const fundedOutput = outputBudget / outputPrice;
208
+ if (fundedOutput < BigInt(maxTok)) maxTok = Number(fundedOutput);
209
+ if (maxTok < 1) return { refuse: true, reason: 'output', ...details };
210
+ return { refuse: false, maxTok, ...details };
223
211
  }
224
212
 
225
213
  // The core factory. `verifier` is an mtok-verify createOnchainVerifier instance (or anything
@@ -235,13 +223,41 @@ export function createServeCore({
235
223
  offerId, sellerAgentId, sellerWallet,
236
224
  dripContractAddress, feeRecipient, feeBps,
237
225
  screenPayer,
226
+ // A retired payment domain may honor verified receipts without admitting
227
+ // quotes or another inference attempt for an unrecorded payment.
228
+ replayOnly = false,
229
+ // The host counts its actual sanitized provider input. Missing or failed
230
+ // accounting refuses a fresh claim; saved completions do not need recounting.
231
+ countInputTokens,
232
+ maxInputTokens = DEFAULT_MAX_INPUT_TOKENS,
238
233
  // #654: the output-token sanity ceiling for boundServe. The PAID budget already
239
234
  // bounds output (and metering is on real usage), so this is a defensive cap on a
240
235
  // single generation, not a money guard. It is a per-relay knob: the reference host
241
236
  // passes MTOK_MAX_OUTPUT_TOKENS / a config value; operators serving large-context
242
237
  // models raise it. Falls back to boundServe's own generous default when unset.
243
- maxOutputTokens,
238
+ maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS,
244
239
  }) {
240
+ const quote = async (request) => {
241
+ if (replayOnly) return { status: 503, body: { error: 'redemption_unavailable', detail: 'this relay only replays saved completions' } };
242
+ const checked = validateRequest(request, model);
243
+ if (checked.error) return { status: 400, body: { error: 'bad_request', detail: checked.error } };
244
+ try {
245
+ const inputTokens = await countInputTokens(checked.safeRequest);
246
+ if (!withinInputLimit(inputTokens, maxInputTokens)) return { status: 413, body: { error: 'input_too_large', detail: `input (${inputTokens} tokens) exceeds this relay's input-token limit` } };
247
+ if (!Number.isSafeInteger(maxOutputTokens) || maxOutputTokens <= 0) throw new TypeError('output token ceiling must be a positive safe integer');
248
+ const inputPrice = offerPriceAtomic(inPrice);
249
+ const outputPrice = offerPriceAtomic(outPrice);
250
+ if (inputPrice < 0n || outputPrice <= 0n) throw new RangeError('invalid offer prices');
251
+ return { status: 200, body: {
252
+ model, offerId, inputTokens, maxInputTokens, maxOutputTokens,
253
+ inputPricePerMTokAtomic: inputPrice.toString(),
254
+ outputPricePerMTokAtomic: outputPrice.toString(),
255
+ } };
256
+ } catch (e) {
257
+ return { status: 503, body: { error: 'input_accounting_unavailable', detail: e.message } };
258
+ }
259
+ };
260
+
245
261
  const serve = async (body) => {
246
262
  const currentFeeRecipient = typeof feeRecipient === 'function' ? feeRecipient() : feeRecipient;
247
263
  const { bookingId, n, buyerId, request, requestNonce, drawPaidTxHash } = body;
@@ -341,6 +357,9 @@ export function createServeCore({
341
357
  if (redemptionState === 'pending') {
342
358
  return { status: 409, body: { error: 'draw_pending', detail: 'this paid draw was already claimed; refusing to run upstream again', _bookingId: bookingId } };
343
359
  }
360
+ if (replayOnly) {
361
+ return { status: 503, body: { error: 'redemption_unavailable', detail: 'this payment has no saved completion; retry this same paid draw after reconciliation', _bookingId: bookingId } };
362
+ }
344
363
  // Completed/pending claims already crossed the fee gate and cannot spend
345
364
  // again. New claims use the policy at the verified payment time.
346
365
  let currentFeeBps;
@@ -360,21 +379,32 @@ export function createServeCore({
360
379
  return { status: 402, body: { error: 'balance_exhausted', detail: `remainingUsd=${remainingUsd}`, _bookingId: bookingId, remainingUsd } };
361
380
  }
362
381
 
363
- // Bound BOTH legs against what the draw paid for (#495/#460). The relay used
364
- // to cap only OUTPUT, so a dust draw + a huge prompt got its output capped but
365
- // the whole prompt forwarded => the seller ate unbounded upstream INPUT compute.
366
- // Now: REFUSE before any upstream call if the estimated input cost alone meets
367
- // the payment, else cap output over the budget LEFT after input. Input is priced
368
- // at the higher of our offer price and the buyer's committed event price, so the
369
- // buyer can't zero the input leg to sneak a big prompt.
370
- const eventInPriceUsd = Number(paidEvent.inputPricePerMTokAtomic || 0) / 1e6;
371
- const eventOutPriceUsd = Number(paidEvent.outputPricePerMTokAtomic || 0) / 1e6;
372
- const boundInPrice = Math.max(Number(inPrice) || 0, eventInPriceUsd);
373
- const boundOutPrice = Math.max(Number(outPrice) || 0, eventOutPriceUsd);
374
- 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)) } : {}) });
382
+ let bound;
383
+ try {
384
+ const inputTokens = await countInputTokens(checked.safeRequest);
385
+ const price = (offered, committed) => {
386
+ const local = offerPriceAtomic(offered);
387
+ const paid = BigInt(committed);
388
+ return local > paid ? local : paid;
389
+ };
390
+ bound = boundServe({
391
+ inputTokens,
392
+ budgetUsdAtomic: paidEvent.sellerUsdAtomic,
393
+ inPriceAtomic: price(inPrice, paidEvent.inputPricePerMTokAtomic),
394
+ outPriceAtomic: price(outPrice, paidEvent.outputPricePerMTokAtomic),
395
+ reqMax: checked.safeRequest.max_tokens,
396
+ inputCeil: maxInputTokens,
397
+ contextCeil: maxOutputTokens,
398
+ });
399
+ } catch (e) {
400
+ return { status: 503, body: { error: 'input_accounting_unavailable', detail: e.message, _bookingId: bookingId } };
401
+ }
375
402
  if (bound.refuse) {
376
- const error = bound.reason === 'input' ? 'input_too_large' : 'output_unfunded';
377
- 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 } };
403
+ const error = bound.reason === 'input' || bound.reason === 'input_limit' ? 'input_too_large' : 'output_unfunded';
404
+ const detail = bound.reason === 'input_limit'
405
+ ? `input (${bound.inputTokens} tokens) exceeds this relay's input-token limit`
406
+ : `input (${bound.inputTokens} tokens, $${bound.inputCostUsd.toFixed(6)}) leaves no safely funded output in the paid amount ($${remainingUsd})`;
407
+ return { status: 402, body: { error, detail, _bookingId: bookingId, remainingUsd } };
378
408
  }
379
409
  const safeRequest = { ...checked.safeRequest, model, max_tokens: bound.maxTok };
380
410
 
@@ -428,5 +458,5 @@ export function createServeCore({
428
458
  return { status: 200, body: payload };
429
459
  };
430
460
 
431
- return { serve };
461
+ return { serve, quote };
432
462
  }