mtok-bridge 0.3.1 → 0.3.3
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/bridge.mjs +2 -1
- package/src/serve-core.mjs +98 -32
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.3",
|
|
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
|
@@ -55,7 +55,7 @@ export async function serveChat({ body, authHeader, apiKey, models, upstream })
|
|
|
55
55
|
// `baseUrl` is the API root (e.g. https://api.openai.com/v1 or a local model server); `key` is
|
|
56
56
|
// its bearer token (optional for a keyless local server). This is what makes the bridge portable:
|
|
57
57
|
// point it at a provider, or at ollama / LM Studio / vLLM on localhost.
|
|
58
|
-
export function httpUpstream({ baseUrl, key }) {
|
|
58
|
+
export function httpUpstream({ baseUrl, key, timeoutMs }) {
|
|
59
59
|
const url = String(baseUrl || '').replace(/\/$/, '') + '/chat/completions';
|
|
60
60
|
return async (payload) => {
|
|
61
61
|
const res = await fetch(url, {
|
|
@@ -65,6 +65,7 @@ export function httpUpstream({ baseUrl, key }) {
|
|
|
65
65
|
...(key ? { authorization: `Bearer ${key}` } : {}),
|
|
66
66
|
},
|
|
67
67
|
body: JSON.stringify(payload),
|
|
68
|
+
...(timeoutMs == null ? {} : { signal: AbortSignal.timeout(timeoutMs) }),
|
|
68
69
|
});
|
|
69
70
|
const text = await res.text();
|
|
70
71
|
let json;
|
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
|
|
|
@@ -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
|
-
|
|
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' } };
|
|
@@ -221,6 +282,24 @@ export function createServeCore({
|
|
|
221
282
|
return { status: 402, body: { error: 'contract_mode_required', detail: 'this relay only serves contract-mode draws; the platform is not reporting a dripContractAddress' } };
|
|
222
283
|
}
|
|
223
284
|
if (!drawPaidTxHash) return { status: 402, body: { error: 'draw_payment_required', detail: 'contract mode requires drawPaidTxHash before upstream delivery' } };
|
|
285
|
+
let storedKey = cacheKey;
|
|
286
|
+
// The redemption interface may be sync (fs store) or async (a Workers KV
|
|
287
|
+
// store): await normalizes both, and identity-awaits cost nothing under the
|
|
288
|
+
// host's per-booking lock.
|
|
289
|
+
let redemptionState = await redemption.state(storedKey);
|
|
290
|
+
// Preserve an upgrade's pre-scheme redemption log; missing this alias
|
|
291
|
+
// would let a legacy paid draw run upstream again after relay upgrade.
|
|
292
|
+
if (!redemptionState && oldLegacyKey) {
|
|
293
|
+
const oldLegacyState = await redemption.state(oldLegacyKey);
|
|
294
|
+
if (oldLegacyState) {
|
|
295
|
+
storedKey = oldLegacyKey;
|
|
296
|
+
redemptionState = oldLegacyState;
|
|
297
|
+
} else {
|
|
298
|
+
// Checking the legacy marker may have refreshed a prefixed record
|
|
299
|
+
// appended by another upgraded process.
|
|
300
|
+
redemptionState = await redemption.state(cacheKey);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
224
303
|
let paid;
|
|
225
304
|
try {
|
|
226
305
|
paid = await verifier.verifyDrawPaid(drawPaidTxHash, {
|
|
@@ -233,22 +312,21 @@ export function createServeCore({
|
|
|
233
312
|
n,
|
|
234
313
|
requestHash,
|
|
235
314
|
sellerWallet,
|
|
236
|
-
feeRecipient,
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
// skip-on-unreadable-block residual is named in redemption.mjs). An
|
|
241
|
-
// honest retry is seconds-to-minutes old, never days.
|
|
242
|
-
maxPaidAgeMs: redemption.retentionMs,
|
|
315
|
+
feeRecipient: currentFeeRecipient,
|
|
316
|
+
// A known completion spends no new inference. New claims need a verified
|
|
317
|
+
// age because both payload and claim markers expire after retention.
|
|
318
|
+
maxPaidAgeMs: redemptionState === 'complete' ? undefined : redemption.retentionMs,
|
|
243
319
|
});
|
|
244
320
|
} catch (e) {
|
|
321
|
+
if (e.name === 'TimeoutError') return { status: 503, body: { error: 'relay_timeout', _bookingId: bookingId } };
|
|
245
322
|
return { status: 402, body: { error: 'payment_unverified', detail: e.message } };
|
|
246
323
|
}
|
|
324
|
+
if (paid?.reason === 'payment_age_unavailable') return { status: 503, body: { error: 'payment_age_unavailable', detail: 'payment age could not be verified; retry this same paid draw', _bookingId: bookingId } };
|
|
247
325
|
if (!paid?.ok) return { status: 402, body: { error: 'payment_unverified', detail: paid?.reason || 'unknown' } };
|
|
248
326
|
const expectedFee = configuredFeeAtomic({
|
|
249
327
|
sellerUsdAtomic: paid.event.sellerUsdAtomic,
|
|
250
|
-
feeAddress:
|
|
251
|
-
feeBps,
|
|
328
|
+
feeAddress: currentFeeRecipient,
|
|
329
|
+
feeBps: currentFeeBps,
|
|
252
330
|
});
|
|
253
331
|
if (BigInt(paid.event.feeUsdAtomic || 0) < expectedFee) {
|
|
254
332
|
return { status: 402, body: { error: 'payment_unverified', detail: 'fee_amount_too_low' } };
|
|
@@ -267,24 +345,6 @@ export function createServeCore({
|
|
|
267
345
|
return { status: 403, body: { error: 'payer_denied', detail: 'payer screening failed: ' + e.message } };
|
|
268
346
|
}
|
|
269
347
|
}
|
|
270
|
-
let storedKey = cacheKey;
|
|
271
|
-
// The redemption interface may be sync (fs store) or async (a Workers KV
|
|
272
|
-
// store): await normalizes both, and identity-awaits cost nothing under the
|
|
273
|
-
// host's per-booking lock.
|
|
274
|
-
let redemptionState = await redemption.state(storedKey);
|
|
275
|
-
// Preserve an upgrade's pre-scheme redemption log; missing this alias
|
|
276
|
-
// would let a legacy paid draw run upstream again after relay upgrade.
|
|
277
|
-
if (!redemptionState && oldLegacyKey) {
|
|
278
|
-
const oldLegacyState = await redemption.state(oldLegacyKey);
|
|
279
|
-
if (oldLegacyState) {
|
|
280
|
-
storedKey = oldLegacyKey;
|
|
281
|
-
redemptionState = oldLegacyState;
|
|
282
|
-
} else {
|
|
283
|
-
// Checking the legacy marker may have refreshed a prefixed record
|
|
284
|
-
// appended by another upgraded process.
|
|
285
|
-
redemptionState = await redemption.state(cacheKey);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
348
|
if (redemptionState === 'complete') return { status: 200, body: await redemption.get(storedKey) };
|
|
289
349
|
if (redemptionState === 'pending') {
|
|
290
350
|
return { status: 409, body: { error: 'draw_pending', detail: 'this paid draw was already claimed; refusing to run upstream again', _bookingId: bookingId } };
|
|
@@ -306,7 +366,7 @@ export function createServeCore({
|
|
|
306
366
|
const eventOutPriceUsd = Number(paidEvent.outputPricePerMTokAtomic || 0) / 1e6;
|
|
307
367
|
const boundInPrice = Math.max(Number(inPrice) || 0, eventInPriceUsd);
|
|
308
368
|
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 });
|
|
369
|
+
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
370
|
if (bound.refuse) {
|
|
311
371
|
const error = bound.reason === 'input' ? 'input_too_large' : 'output_unfunded';
|
|
312
372
|
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 } };
|
|
@@ -327,11 +387,17 @@ export function createServeCore({
|
|
|
327
387
|
try {
|
|
328
388
|
completion = await upstream(safeRequest);
|
|
329
389
|
} catch (e) {
|
|
390
|
+
if (e.name === 'TimeoutError') return { status: 503, body: { error: 'relay_timeout', _bookingId: bookingId } };
|
|
330
391
|
return { status: 502, body: { error: 'upstream_error', detail: e.message } };
|
|
331
392
|
}
|
|
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
|