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 +19 -4
- package/package.json +1 -1
- package/src/serve-core.mjs +99 -17
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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.
|
|
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": {
|
package/src/serve-core.mjs
CHANGED
|
@@ -105,11 +105,53 @@ function validateRequest(request, model, { legacy = false } = {}) {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
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 (
|
|
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
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
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
|
|
186
|
+
let bytes = 0;
|
|
187
|
+
let envelope = 3; // reply priming
|
|
130
188
|
for (const m of messages ?? []) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|