mtok-bridge 0.3.1 → 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.1",
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
 
@@ -158,7 +200,14 @@ export function estimateInputTokens(messages) {
158
200
  // exceeds the payment; otherwise cap output over the budget LEFT after input. inPrice
159
201
  // and outPrice are USD per MTok. The estimate gates the refuse ONLY; real billing
160
202
  // still meters the upstream's reported token counts, so this never over-charges.
161
- 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 }) {
162
211
  const estIn = estimateInputTokens(messages);
163
212
  const estInCostUsd = estIn * (Number(inPrice) > 0 ? Number(inPrice) : 0) / 1e6;
164
213
  if (estInCostUsd >= budgetUsd) return { refuse: true, reason: 'input', estIn, estInCostUsd };
@@ -186,8 +235,20 @@ export function createServeCore({
186
235
  offerId, sellerAgentId, sellerWallet,
187
236
  dripContractAddress, feeRecipient, feeBps,
188
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,
189
244
  }) {
190
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;
191
252
  const { bookingId, n, buyerId, request, requestNonce, drawPaidTxHash } = body;
192
253
  const hasRequestNonce = Object.hasOwn(body, 'requestNonce');
193
254
  if (!bookingId) return { status: 400, body: { error: 'bad_request', detail: 'DRAW needs bookingId' } };
@@ -233,7 +294,7 @@ export function createServeCore({
233
294
  n,
234
295
  requestHash,
235
296
  sellerWallet,
236
- feeRecipient,
297
+ feeRecipient: currentFeeRecipient,
237
298
  // #580: refuse a payment older than the redemption window. The JSONL
238
299
  // payload cache AND the claim markers are both aged out at boot (#600),
239
300
  // so past retention this age bound is the sole replay defense (its
@@ -247,8 +308,8 @@ export function createServeCore({
247
308
  if (!paid?.ok) return { status: 402, body: { error: 'payment_unverified', detail: paid?.reason || 'unknown' } };
248
309
  const expectedFee = configuredFeeAtomic({
249
310
  sellerUsdAtomic: paid.event.sellerUsdAtomic,
250
- feeAddress: feeRecipient,
251
- feeBps,
311
+ feeAddress: currentFeeRecipient,
312
+ feeBps: currentFeeBps,
252
313
  });
253
314
  if (BigInt(paid.event.feeUsdAtomic || 0) < expectedFee) {
254
315
  return { status: 402, body: { error: 'payment_unverified', detail: 'fee_amount_too_low' } };
@@ -306,7 +367,7 @@ export function createServeCore({
306
367
  const eventOutPriceUsd = Number(paidEvent.outputPricePerMTokAtomic || 0) / 1e6;
307
368
  const boundInPrice = Math.max(Number(inPrice) || 0, eventInPriceUsd);
308
369
  const boundOutPrice = Math.max(Number(outPrice) || 0, eventOutPriceUsd);
309
- 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)) } : {}) });
310
371
  if (bound.refuse) {
311
372
  const error = bound.reason === 'input' ? 'input_too_large' : 'output_unfunded';
312
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 } };
@@ -332,6 +393,11 @@ export function createServeCore({
332
393
 
333
394
  try { enforceModelEcho(completion.model, model); }
334
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;
335
401
 
336
402
  // ── Contract mode is REPORT-FREE (chain-native phase 2 stage 3, #387) ──
337
403
  // The verified DrawPaid event IS the record: the platform indexes the