mtok-bridge 0.3.0 → 0.3.2

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/mtok-bridge.mjs CHANGED
@@ -36,11 +36,26 @@ if (!o.upstream) { console.error('need --upstream <openai-compatible url> (e.g.
36
36
  const apiKey = o.keyless ? null : (o.apiKey || 'mtok_' + crypto.randomBytes(24).toString('hex'));
37
37
  const upstream = httpUpstream({ baseUrl: o.upstream, key: o.upstreamKey });
38
38
 
39
+ const MAX_BODY_BYTES = 2_000_000;
39
40
  const readBody = (req) => new Promise((resolve) => {
40
- let data = ''; let over = false;
41
- req.on('data', (c) => { data += c; if (data.length > 2_000_000) { over = true; req.destroy(); } });
42
- req.on('end', () => resolve(over ? null : data));
43
- req.on('error', () => resolve(null));
41
+ // #651: buffer the raw chunks and decode ONCE. `data += chunk` stringified each
42
+ // Buffer as it arrived, which corrupts a multibyte UTF-8 character split across
43
+ // two TCP chunks (each half decodes to replacement bytes). Track the BYTE total
44
+ // (not string .length) against the cap, and on overflow resolve immediately so an
45
+ // oversized request gets its 413 instead of leaving the handler awaiting forever.
46
+ const chunks = [];
47
+ let bytes = 0;
48
+ let settled = false;
49
+ const done = (value) => { if (!settled) { settled = true; resolve(value); } };
50
+ req.on('data', (c) => {
51
+ if (settled) return;
52
+ bytes += c.length;
53
+ if (bytes > MAX_BODY_BYTES) { req.destroy(); return done(null); }
54
+ chunks.push(c);
55
+ });
56
+ req.on('end', () => done(Buffer.concat(chunks).toString('utf8')));
57
+ req.on('error', () => done(null));
58
+ req.on('close', () => done(null)); // destroy() emits close, not always end/error
44
59
  });
45
60
  const send = (res, status, json) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(json)); };
46
61
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtok-bridge",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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": {
@@ -105,11 +105,53 @@ function validateRequest(request, model, { legacy = false } = {}) {
105
105
  }
106
106
 
107
107
  /**
108
- * Throws if the upstream model doesn't match the offer model.
109
- * Protects against cheap-swap accusations: echo the real model you delivered.
108
+ * Normalize a model id for comparison: lowercase, drop any provider namespace
109
+ * (everything through the last '/', e.g. `@cf/meta/`), and strip a leading '@'.
110
+ * `@cf/meta/llama-3.1-8b-instruct-fp8` and `llama-3.1-8b-instruct-fp8` normalize
111
+ * to the same stem.
112
+ */
113
+ export function normalizeModelId(m) {
114
+ return String(m ?? '').toLowerCase().split('/').pop().replace(/^@/, '');
115
+ }
116
+
117
+ /**
118
+ * True when the upstream's echoed model is a legitimate variant of the offer
119
+ * model. Legitimate variance is ONLY a resolved version/snapshot SUFFIX appended
120
+ * to the same base (`gpt-4o` -> `gpt-4o-2024-08-06`, `gpt-4` -> `gpt-4-0613`): the
121
+ * extra segment starts with a DIGIT. A raw prefix match is NOT enough -- model
122
+ * families share alphabetic prefixes (`gpt-4` is a prefix of the CHEAPER
123
+ * `gpt-4o-mini`; `claude-3` of `claude-3-haiku`), so accepting any prefix would
124
+ * pass a cheap-swap. Require the appended segment to be version-like (digit-led),
125
+ * which distinguishes a snapshot date/version from a different model qualifier
126
+ * (mini, haiku, turbo, fp8, instruct).
127
+ */
128
+ export function modelsCompatible(upstreamModel, offerModel) {
129
+ const a = normalizeModelId(upstreamModel);
130
+ const b = normalizeModelId(offerModel);
131
+ if (!a || !b) return false;
132
+ if (a === b) return true;
133
+ // The longer must equal the shorter + a version/snapshot suffix made ONLY of
134
+ // digit groups separated by -/./_ (a date like -2024-08-06, a build like -0613,
135
+ // a point version like .1). Any alphabetic segment in the suffix means a
136
+ // different model qualifier (mini, haiku, turbo, instruct, fp8), so e.g.
137
+ // gpt-4.1-mini vs gpt-4 and claude-3.5-haiku vs claude-3 are rejected while a
138
+ // real snapshot passes.
139
+ const [longer, shorter] = a.length >= b.length ? [a, b] : [b, a];
140
+ if (!longer.startsWith(shorter)) return false;
141
+ const rest = longer.slice(shorter.length);
142
+ return /^([-._]\d+)+$/.test(rest);
143
+ }
144
+
145
+ /**
146
+ * Throws if the upstream model is NOT a legitimate variant of the offer model.
147
+ * #651: the old check was an exact string compare, which false-refused honest
148
+ * sellers whose OpenAI-compatible provider echoes a snapshot id or a namespace-
149
+ * stripped name (the buyer's own accept-check is exact too, so the seller then
150
+ * normalizes the delivered model to the offer id below). A real cheap-swap to a
151
+ * different model family still fails this tolerant relation.
110
152
  */
111
153
  export function enforceModelEcho(upstreamModel, offerModel) {
112
- if (String(upstreamModel) !== String(offerModel))
154
+ if (!modelsCompatible(upstreamModel, offerModel))
113
155
  throw new Error(`model mismatch: upstream ${upstreamModel} != offer ${offerModel}`);
114
156
  }
115
157
 
@@ -119,20 +161,36 @@ export function configuredFeeAtomic({ sellerUsdAtomic, feeAddress, feeBps }) {
119
161
  return (BigInt(sellerUsdAtomic || 0) * bps + 5000n) / 10000n;
120
162
  }
121
163
 
122
- // Conservative tokenizer-independent upper estimate: a tokenizer cannot consume
123
- // more text tokens than UTF-8 bytes, plus the chat envelope around each message.
124
- // The core accepts plain-text messages only, so no unpriced multimodal parts
125
- // can bypass this bound. Pure, dependency-free, no I/O.
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.
126
182
  export const MESSAGE_OVERHEAD_TOKENS = 4;
183
+ export const BYTES_PER_TOKEN_EST = 3.2;
127
184
  export function estimateInputTokens(messages) {
128
185
  const utf8 = new TextEncoder();
129
- let tokens = 3; // reply priming
186
+ let bytes = 0;
187
+ let envelope = 3; // reply priming
130
188
  for (const m of messages ?? []) {
131
- tokens += MESSAGE_OVERHEAD_TOKENS;
132
- tokens += utf8.encode(String(m?.role ?? '')).length;
133
- tokens += utf8.encode(typeof m?.content === 'string' ? m.content : JSON.stringify(m?.content ?? null)).length;
189
+ envelope += MESSAGE_OVERHEAD_TOKENS;
190
+ bytes += utf8.encode(String(m?.role ?? '')).length;
191
+ bytes += utf8.encode(typeof m?.content === 'string' ? m.content : JSON.stringify(m?.content ?? null)).length;
134
192
  }
135
- return tokens;
193
+ return envelope + Math.ceil(bytes / BYTES_PER_TOKEN_EST);
136
194
  }
137
195
 
138
196
  // Bound a serve against the paid budget in BOTH legs (#495/#460). The relay used
@@ -142,7 +200,14 @@ export function estimateInputTokens(messages) {
142
200
  // exceeds the payment; otherwise cap output over the budget LEFT after input. inPrice
143
201
  // and outPrice are USD per MTok. The estimate gates the refuse ONLY; real billing
144
202
  // still meters the upstream's reported token counts, so this never over-charges.
145
- export function boundServe({ messages, budgetUsd, inPrice, outPrice, reqMax, contextCeil = 4096 }) {
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.
209
+ export const DEFAULT_MAX_OUTPUT_TOKENS = 32768;
210
+ export function boundServe({ messages, budgetUsd, inPrice, outPrice, reqMax, contextCeil = DEFAULT_MAX_OUTPUT_TOKENS }) {
146
211
  const estIn = estimateInputTokens(messages);
147
212
  const estInCostUsd = estIn * (Number(inPrice) > 0 ? Number(inPrice) : 0) / 1e6;
148
213
  if (estInCostUsd >= budgetUsd) return { refuse: true, reason: 'input', estIn, estInCostUsd };
@@ -170,8 +235,20 @@ export function createServeCore({
170
235
  offerId, sellerAgentId, sellerWallet,
171
236
  dripContractAddress, feeRecipient, feeBps,
172
237
  screenPayer,
238
+ // #654: the output-token sanity ceiling for boundServe. The PAID budget already
239
+ // bounds output (and metering is on real usage), so this is a defensive cap on a
240
+ // single generation, not a money guard. It is a per-relay knob: the reference host
241
+ // passes MTOK_MAX_OUTPUT_TOKENS / a config value; operators serving large-context
242
+ // models raise it. Falls back to boundServe's own generous default when unset.
243
+ maxOutputTokens,
173
244
  }) {
174
245
  const serve = async (body) => {
246
+ // #651: feeBps/feeRecipient may be getters so a long-running relay tracks a
247
+ // platform fee change instead of pinning the boot rate (a fee DECREASE with a
248
+ // stale higher rate would refuse an already-paid draw as fee_amount_too_low).
249
+ // Resolve once per serve and use the resolved values everywhere below.
250
+ const currentFeeBps = typeof feeBps === 'function' ? feeBps() : feeBps;
251
+ const currentFeeRecipient = typeof feeRecipient === 'function' ? feeRecipient() : feeRecipient;
175
252
  const { bookingId, n, buyerId, request, requestNonce, drawPaidTxHash } = body;
176
253
  const hasRequestNonce = Object.hasOwn(body, 'requestNonce');
177
254
  if (!bookingId) return { status: 400, body: { error: 'bad_request', detail: 'DRAW needs bookingId' } };
@@ -217,7 +294,7 @@ export function createServeCore({
217
294
  n,
218
295
  requestHash,
219
296
  sellerWallet,
220
- feeRecipient,
297
+ feeRecipient: currentFeeRecipient,
221
298
  // #580: refuse a payment older than the redemption window. The JSONL
222
299
  // payload cache AND the claim markers are both aged out at boot (#600),
223
300
  // so past retention this age bound is the sole replay defense (its
@@ -231,8 +308,8 @@ export function createServeCore({
231
308
  if (!paid?.ok) return { status: 402, body: { error: 'payment_unverified', detail: paid?.reason || 'unknown' } };
232
309
  const expectedFee = configuredFeeAtomic({
233
310
  sellerUsdAtomic: paid.event.sellerUsdAtomic,
234
- feeAddress: feeRecipient,
235
- feeBps,
311
+ feeAddress: currentFeeRecipient,
312
+ feeBps: currentFeeBps,
236
313
  });
237
314
  if (BigInt(paid.event.feeUsdAtomic || 0) < expectedFee) {
238
315
  return { status: 402, body: { error: 'payment_unverified', detail: 'fee_amount_too_low' } };
@@ -290,7 +367,7 @@ export function createServeCore({
290
367
  const eventOutPriceUsd = Number(paidEvent.outputPricePerMTokAtomic || 0) / 1e6;
291
368
  const boundInPrice = Math.max(Number(inPrice) || 0, eventInPriceUsd);
292
369
  const boundOutPrice = Math.max(Number(outPrice) || 0, eventOutPriceUsd);
293
- const bound = boundServe({ messages: checked.safeRequest.messages, budgetUsd: remainingUsd, inPrice: boundInPrice, outPrice: boundOutPrice, reqMax: checked.safeRequest.max_tokens });
370
+ 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)) } : {}) });
294
371
  if (bound.refuse) {
295
372
  const error = bound.reason === 'input' ? 'input_too_large' : 'output_unfunded';
296
373
  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 } };
@@ -316,6 +393,11 @@ export function createServeCore({
316
393
 
317
394
  try { enforceModelEcho(completion.model, model); }
318
395
  catch (e) { return { status: 502, body: { error: 'model_mismatch', detail: e.message } }; }
396
+ // #651: the upstream model passed the tolerant variant check; echo the OFFER
397
+ // model id in the delivered completion. The buyer's accept-check is an exact
398
+ // completion.model === offer.model compare, so a legitimate snapshot/namespace
399
+ // variant must be normalized to the offer id or an honest paid draw is disputed.
400
+ if (completion && typeof completion === 'object') completion.model = model;
319
401
 
320
402
  // ── Contract mode is REPORT-FREE (chain-native phase 2 stage 3, #387) ──
321
403
  // The verified DrawPaid event IS the record: the platform indexes the