mtok-bridge 0.2.0 → 0.3.0

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
@@ -35,6 +35,16 @@ want to get PAID for a model (on-chain, per call, in USDC on Base) and be discov
35
35
  board instead of handing out keys, the market relay wraps this exact bridge with settlement. Same
36
36
  tool, one layer on top.
37
37
 
38
+ ## the shared serve core (for market hosts)
39
+
40
+ `mtok-bridge` also exports `createServeCore`, the paid-serve state machine the market relay
41
+ (and the workers-ai house seller) run: validate request => verify the on-chain DrawPaid
42
+ (request-hash bound) => bound both legs against the payment => claim => upstream => complete,
43
+ fail-closed. The redemption store is a pluggable interface (`{ state, get, claim, complete,
44
+ retentionMs }`, each method sync or async: the core awaits every call), so a filesystem store and a Workers KV store drive the same code path. If you
45
+ are just serving a model for a key, you never need it; it is here so every mtok seller host
46
+ shares one money path.
47
+
38
48
 
39
49
  ---
40
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtok-bridge",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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
@@ -4,6 +4,10 @@
4
4
  // later, the market relay on top) adapts its req/res to them. The market layer (on-chain verify,
5
5
  // fee, redemption, discovery) is a SEPARATE wrapper that calls the same serveChat guts; it is not
6
6
  // in here. (#566)
7
+ //
8
+ // The PAID-serve state machine those market hosts share lives in serve-core.mjs (#603) and is
9
+ // re-exported here so `import { createServeCore } from 'mtok-bridge'` just works.
10
+ export * from './serve-core.mjs';
7
11
 
8
12
  // Bearer-key auth. No key configured (null/'') = OPEN on purpose (keyless mode is an explicit
9
13
  // opt-in the CLI announces loudly). A configured key must match exactly.
@@ -0,0 +1,345 @@
1
+ // mtok-bridge serve core (#603): the PAID-serve state machine shared by every mtok seller host.
2
+ // validate request => verify DrawPaid (request-hash bound) => screen payer => bound both legs =>
3
+ // claim => upstream => complete, with the exact fail-closed semantics hardened in #597: a
4
+ // post-claim failure leaves the draw durably pending (409 terminal on retry), so one payment can
5
+ // never buy a second upstream spend. Host-agnostic and dependency-free (web crypto only): the
6
+ // node relay composes it with its fs redemption store; the workers-ai house seller composes the
7
+ // same core with a KV-backed store. The http shell, booking locks, platform config fetch, and
8
+ // payer-screen POLICY stay in the host.
9
+ //
10
+ // The redemption dependency is an INTERFACE the caller passes:
11
+ // { state(key), get(key), claim(key, markerKey), complete(key, payload), retentionMs }
12
+ // state(key) returns 'pending' | 'complete' | null. claim(key, markerKey) returns false when the
13
+ // draw is already claimed and THROWS when it cannot claim durably (the core then refuses before
14
+ // upstream spend). complete(key, payload) throws when the payload cannot be persisted; the claim
15
+ // then stays pending on purpose so every replay fails closed instead of spending upstream again.
16
+
17
+ const BALANCE_EPSILON = 1e-6;
18
+ const REQUEST_NONCE_RE = /^0x[0-9a-fA-F]{32}$/;
19
+ const CHAT_ROLES = new Set(['developer', 'system', 'user', 'assistant']);
20
+ const REQUEST_KEYS = new Set(['model', 'messages', 'max_tokens', 'temperature', 'response_format', 'stream', 'n']);
21
+
22
+ // sha256 over the request commitment, via web crypto so the identical bytes come out on node
23
+ // (>=20 has globalThis.crypto) and on workers. Same output as node's createHash('sha256').
24
+ async function hash32(v) {
25
+ const bytes = new TextEncoder().encode(typeof v === 'string' ? v : JSON.stringify(v ?? null));
26
+ const digest = await crypto.subtle.digest('SHA-256', bytes);
27
+ return '0x' + [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
28
+ }
29
+
30
+ function legacyContentText(content) {
31
+ const render = (part) => {
32
+ if (typeof part === 'string') return part;
33
+ if (Array.isArray(part)) return part.map(render).filter(Boolean).join('\n');
34
+ if (part == null) return '';
35
+ if (typeof part !== 'object') return String(part);
36
+ if (typeof part.text === 'string') return part.text;
37
+ if (typeof part.content === 'string') return part.content;
38
+ const type = typeof part.type === 'string' && /^[a-z0-9_-]{1,32}$/i.test(part.type) ? part.type : 'non-text';
39
+ return `[${type} omitted]`;
40
+ };
41
+ return render(content) || '[empty legacy content]';
42
+ }
43
+
44
+ function sanitizeLegacyMessage(message) {
45
+ const source = message && typeof message === 'object' && !Array.isArray(message) ? message : { content: message };
46
+ if (CHAT_ROLES.has(source.role)) return { role: source.role, content: legacyContentText(source.content) };
47
+ const label = source.role === 'tool' || source.role === 'function' ? source.role : 'legacy';
48
+ return { role: 'user', content: `[${label} message]\n${legacyContentText(source.content)}` };
49
+ }
50
+
51
+ function validateRequest(request, model, { legacy = false } = {}) {
52
+ if (!request || typeof request !== 'object' || Array.isArray(request)) {
53
+ return { error: 'request must be an object' };
54
+ }
55
+ const unknown = Object.keys(request).find((key) => !REQUEST_KEYS.has(key));
56
+ if (unknown && !legacy) return { error: `unsupported request field: ${unknown}` };
57
+ if (!legacy && request.model != null && String(request.model) !== String(model)) {
58
+ return { error: `request model ${request.model} is not served here` };
59
+ }
60
+ if (!Array.isArray(request.messages) || request.messages.length === 0) {
61
+ return { error: 'request.messages must be a nonempty array' };
62
+ }
63
+ if (!legacy) {
64
+ for (const message of request.messages) {
65
+ if (!message || typeof message !== 'object' || Array.isArray(message)) {
66
+ return { error: 'each message must be an object' };
67
+ }
68
+ const extra = Object.keys(message).find((key) => key !== 'role' && key !== 'content');
69
+ if (extra) return { error: `unsupported message field: ${extra}` };
70
+ if (!CHAT_ROLES.has(message.role)) return { error: `unsupported message role: ${message.role}` };
71
+ if (typeof message.content !== 'string') return { error: 'message content must be plain text' };
72
+ }
73
+ }
74
+ if (!legacy && request.stream != null && request.stream !== false) return { error: 'streaming is not supported' };
75
+ if (!legacy && request.n != null && request.n !== 1) return { error: 'request.n must be 1' };
76
+ const validMaxTokens = Number.isInteger(request.max_tokens) && request.max_tokens > 0;
77
+ if (!legacy && request.max_tokens != null && !validMaxTokens) {
78
+ return { error: 'max_tokens must be a positive integer' };
79
+ }
80
+ const validTemperature = Number.isFinite(request.temperature) && request.temperature >= 0 && request.temperature <= 2;
81
+ if (!legacy && request.temperature != null && !validTemperature) {
82
+ return { error: 'temperature must be between 0 and 2' };
83
+ }
84
+ let validResponseFormat = false;
85
+ if (request.response_format != null) {
86
+ const format = request.response_format;
87
+ validResponseFormat = !!format && typeof format === 'object' && !Array.isArray(format)
88
+ && Object.keys(format).length === 1
89
+ && ['json_object', 'text'].includes(format.type);
90
+ if (!legacy && !validResponseFormat) {
91
+ return { error: 'response_format must be exactly { type: "json_object" } or { type: "text" }' };
92
+ }
93
+ }
94
+ return {
95
+ safeRequest: {
96
+ model,
97
+ messages: legacy
98
+ ? request.messages.map(sanitizeLegacyMessage)
99
+ : request.messages.map(({ role, content }) => ({ role, content })),
100
+ ...(validMaxTokens ? { max_tokens: request.max_tokens } : {}),
101
+ ...(validTemperature ? { temperature: request.temperature } : {}),
102
+ ...(validResponseFormat ? { response_format: { type: request.response_format.type } } : {}),
103
+ },
104
+ };
105
+ }
106
+
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.
110
+ */
111
+ export function enforceModelEcho(upstreamModel, offerModel) {
112
+ if (String(upstreamModel) !== String(offerModel))
113
+ throw new Error(`model mismatch: upstream ${upstreamModel} != offer ${offerModel}`);
114
+ }
115
+
116
+ export function configuredFeeAtomic({ sellerUsdAtomic, feeAddress, feeBps }) {
117
+ const bps = BigInt(Math.trunc(Math.max(0, Number(feeBps) || 0)));
118
+ if (!feeAddress || bps === 0n) return 0n;
119
+ return (BigInt(sellerUsdAtomic || 0) * bps + 5000n) / 10000n;
120
+ }
121
+
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.
126
+ export const MESSAGE_OVERHEAD_TOKENS = 4;
127
+ export function estimateInputTokens(messages) {
128
+ const utf8 = new TextEncoder();
129
+ let tokens = 3; // reply priming
130
+ 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;
134
+ }
135
+ return tokens;
136
+ }
137
+
138
+ // Bound a serve against the paid budget in BOTH legs (#495/#460). The relay used
139
+ // to cap only OUTPUT, so a dust draw + a huge prompt got its output capped but the
140
+ // whole prompt forwarded, making the seller eat unbounded upstream INPUT compute.
141
+ // Estimate the input cost and REFUSE before any upstream call if it alone meets or
142
+ // exceeds the payment; otherwise cap output over the budget LEFT after input. inPrice
143
+ // and outPrice are USD per MTok. The estimate gates the refuse ONLY; real billing
144
+ // still meters the upstream's reported token counts, so this never over-charges.
145
+ export function boundServe({ messages, budgetUsd, inPrice, outPrice, reqMax, contextCeil = 4096 }) {
146
+ const estIn = estimateInputTokens(messages);
147
+ const estInCostUsd = estIn * (Number(inPrice) > 0 ? Number(inPrice) : 0) / 1e6;
148
+ if (estInCostUsd >= budgetUsd) return { refuse: true, reason: 'input', estIn, estInCostUsd };
149
+ const outBudgetUsd = budgetUsd - estInCostUsd;
150
+ let maxTok = contextCeil;
151
+ if (Number(reqMax) > 0) maxTok = Math.min(maxTok, Math.floor(Number(reqMax)));
152
+ if (!Number.isFinite(Number(outPrice)) || Number(outPrice) <= 0) {
153
+ return { refuse: true, reason: 'output_price', estIn, estInCostUsd };
154
+ }
155
+ maxTok = Math.min(maxTok, Math.floor(outBudgetUsd / Number(outPrice) * 1e6));
156
+ if (maxTok < 1) return { refuse: true, reason: 'output', estIn, estInCostUsd };
157
+ return { refuse: false, maxTok, estIn, estInCostUsd };
158
+ }
159
+
160
+ // The core factory. `verifier` is an mtok-verify createOnchainVerifier instance (or anything
161
+ // with the same verifyDrawPaid contract); `redemption` is the store interface documented at the
162
+ // top; `upstream(payload)` returns an OpenAI-shaped completion or throws (httpUpstream /
163
+ // workersAiUpstream from bridge.mjs both satisfy it); `screenPayer(payerLower)` is an optional
164
+ // async predicate the host composes (denylist, hook), truthy = refuse, throw = refuse (fail
165
+ // closed). serve(body) returns { status, body } for the host to serialize; the host owns
166
+ // per-booking serialization (locks) around it.
167
+ export function createServeCore({
168
+ model, inPrice, outPrice,
169
+ verifier, redemption, upstream, log,
170
+ offerId, sellerAgentId, sellerWallet,
171
+ dripContractAddress, feeRecipient, feeBps,
172
+ screenPayer,
173
+ }) {
174
+ const serve = async (body) => {
175
+ const { bookingId, n, buyerId, request, requestNonce, drawPaidTxHash } = body;
176
+ const hasRequestNonce = Object.hasOwn(body, 'requestNonce');
177
+ if (!bookingId) return { status: 400, body: { error: 'bad_request', detail: 'DRAW needs bookingId' } };
178
+ if (n == null) return { status: 400, body: { error: 'bad_request', detail: 'DRAW needs a delivery index n (per-booking idempotency key)' } };
179
+ if (!Number.isSafeInteger(n) || n < 0 || n > 0xffffffff) {
180
+ return { status: 400, body: { error: 'bad_request', detail: 'DRAW delivery index n must be a nonnegative uint32 integer' } };
181
+ }
182
+ if (hasRequestNonce && !REQUEST_NONCE_RE.test(requestNonce)) {
183
+ return { status: 400, body: { error: 'bad_request', detail: 'DRAW needs requestNonce as 16 random bytes encoded as 0x-prefixed hex' } };
184
+ }
185
+ const checked = validateRequest(request, model, { legacy: !hasRequestNonce });
186
+ if (checked.error) return { status: 400, body: { error: 'bad_request', detail: checked.error } };
187
+
188
+ // Legacy SDKs paid for sha256(JSON.stringify(request)) and sent no nonce.
189
+ // Keep those already-paid draws redeemable while current offers advertise
190
+ // nonce-v1 so current SDKs can require the private commitment before paying.
191
+ const requestHashScheme = hasRequestNonce ? 'nonce-v1' : 'legacy-v0';
192
+ const requestHash = hasRequestNonce ? await hash32({ request, requestNonce }) : await hash32(request);
193
+ // Prefix the commitment scheme so legacy and nonce-v1 entries cannot alias.
194
+ // nonce-v1 binds the request to a buyer-held random nonce, preventing a chain
195
+ // observer from guessing a common prompt and deriving its completion key.
196
+ // Legacy redemption remains only to honor draws already paid by old SDKs.
197
+ const cacheKey = `${requestHashScheme}:${bookingId}:${n}:${requestHash}`;
198
+ const oldLegacyKey = hasRequestNonce ? null : `${bookingId}:${n}:${requestHash}`;
199
+
200
+ // Contract mode is the ONLY mode (#487): the legacy direct-transfer FUND
201
+ // lane and its /api/bookings/:id balance read are gone. If the platform is
202
+ // not running the drip contract, REFUSE the draw with a clear error rather
203
+ // than fall back to a lane that no longer exists.
204
+ if (!dripContractAddress) {
205
+ return { status: 402, body: { error: 'contract_mode_required', detail: 'this relay only serves contract-mode draws; the platform is not reporting a dripContractAddress' } };
206
+ }
207
+ if (!drawPaidTxHash) return { status: 402, body: { error: 'draw_payment_required', detail: 'contract mode requires drawPaidTxHash before upstream delivery' } };
208
+ let paid;
209
+ try {
210
+ paid = await verifier.verifyDrawPaid(drawPaidTxHash, {
211
+ contractAddress: dripContractAddress,
212
+ buyerAgentId: buyerId,
213
+ sellerAgentId, // when set, enforces the offer-owner match (#codex review)
214
+ bookingId,
215
+ offerId,
216
+ model,
217
+ n,
218
+ requestHash,
219
+ sellerWallet,
220
+ feeRecipient,
221
+ // #580: refuse a payment older than the redemption window. The JSONL
222
+ // payload cache AND the claim markers are both aged out at boot (#600),
223
+ // so past retention this age bound is the sole replay defense (its
224
+ // skip-on-unreadable-block residual is named in redemption.mjs). An
225
+ // honest retry is seconds-to-minutes old, never days.
226
+ maxPaidAgeMs: redemption.retentionMs,
227
+ });
228
+ } catch (e) {
229
+ return { status: 402, body: { error: 'payment_unverified', detail: e.message } };
230
+ }
231
+ if (!paid?.ok) return { status: 402, body: { error: 'payment_unverified', detail: paid?.reason || 'unknown' } };
232
+ const expectedFee = configuredFeeAtomic({
233
+ sellerUsdAtomic: paid.event.sellerUsdAtomic,
234
+ feeAddress: feeRecipient,
235
+ feeBps,
236
+ });
237
+ if (BigInt(paid.event.feeUsdAtomic || 0) < expectedFee) {
238
+ return { status: 402, body: { error: 'payment_unverified', detail: 'fee_amount_too_low' } };
239
+ }
240
+ // Screen the verified payer before spending upstream capacity. The
241
+ // payment already settled on-chain (that money is the buyer's loss);
242
+ // this refuses the SERVICE, which is the only refusal an edge can
243
+ // still make in contract mode.
244
+ if (screenPayer) {
245
+ try {
246
+ if (await screenPayer(String(paid.from || '').toLowerCase())) {
247
+ return { status: 403, body: { error: 'payer_denied', detail: 'the verified payer wallet is denylisted by this relay' } };
248
+ }
249
+ } catch (e) {
250
+ // A broken screen hook fails CLOSED: do not serve on an unscreenable payer.
251
+ return { status: 403, body: { error: 'payer_denied', detail: 'payer screening failed: ' + e.message } };
252
+ }
253
+ }
254
+ let storedKey = cacheKey;
255
+ // The redemption interface may be sync (fs store) or async (a Workers KV
256
+ // store): await normalizes both, and identity-awaits cost nothing under the
257
+ // host's per-booking lock.
258
+ let redemptionState = await redemption.state(storedKey);
259
+ // Preserve an upgrade's pre-scheme redemption log; missing this alias
260
+ // would let a legacy paid draw run upstream again after relay upgrade.
261
+ if (!redemptionState && oldLegacyKey) {
262
+ const oldLegacyState = await redemption.state(oldLegacyKey);
263
+ if (oldLegacyState) {
264
+ storedKey = oldLegacyKey;
265
+ redemptionState = oldLegacyState;
266
+ } else {
267
+ // Checking the legacy marker may have refreshed a prefixed record
268
+ // appended by another upgraded process.
269
+ redemptionState = await redemption.state(cacheKey);
270
+ }
271
+ }
272
+ if (redemptionState === 'complete') return { status: 200, body: await redemption.get(storedKey) };
273
+ if (redemptionState === 'pending') {
274
+ return { status: 409, body: { error: 'draw_pending', detail: 'this paid draw was already claimed; refusing to run upstream again', _bookingId: bookingId } };
275
+ }
276
+ const paidEvent = paid.event;
277
+ const remainingUsd = Number(paid.event.sellerUsdAtomic || 0) / 1e6;
278
+ if (remainingUsd < BALANCE_EPSILON) {
279
+ return { status: 402, body: { error: 'balance_exhausted', detail: `remainingUsd=${remainingUsd}`, _bookingId: bookingId, remainingUsd } };
280
+ }
281
+
282
+ // Bound BOTH legs against what the draw paid for (#495/#460). The relay used
283
+ // to cap only OUTPUT, so a dust draw + a huge prompt got its output capped but
284
+ // the whole prompt forwarded => the seller ate unbounded upstream INPUT compute.
285
+ // Now: REFUSE before any upstream call if the estimated input cost alone meets
286
+ // the payment, else cap output over the budget LEFT after input. Input is priced
287
+ // at the higher of our offer price and the buyer's committed event price, so the
288
+ // buyer can't zero the input leg to sneak a big prompt.
289
+ const eventInPriceUsd = Number(paidEvent.inputPricePerMTokAtomic || 0) / 1e6;
290
+ const eventOutPriceUsd = Number(paidEvent.outputPricePerMTokAtomic || 0) / 1e6;
291
+ const boundInPrice = Math.max(Number(inPrice) || 0, eventInPriceUsd);
292
+ 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 });
294
+ if (bound.refuse) {
295
+ const error = bound.reason === 'input' ? 'input_too_large' : 'output_unfunded';
296
+ 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 } };
297
+ }
298
+ const safeRequest = { ...checked.safeRequest, model, max_tokens: bound.maxTok };
299
+
300
+ try {
301
+ // Legacy uses its old unprefixed identity only for the atomic marker so
302
+ // parallel upgraded stores and an existing log converge on one claim.
303
+ if (!(await redemption.claim(cacheKey, oldLegacyKey ?? cacheKey))) {
304
+ return { status: 409, body: { error: 'draw_pending', detail: 'this paid draw was already claimed; refusing to run upstream again', _bookingId: bookingId } };
305
+ }
306
+ } catch (e) {
307
+ return { status: 503, body: { error: 'redemption_unavailable', detail: `could not durably claim the paid draw: ${e.message}`, _bookingId: bookingId } };
308
+ }
309
+
310
+ let completion;
311
+ try {
312
+ completion = await upstream(safeRequest);
313
+ } catch (e) {
314
+ return { status: 502, body: { error: 'upstream_error', detail: e.message } };
315
+ }
316
+
317
+ try { enforceModelEcho(completion.model, model); }
318
+ catch (e) { return { status: 502, body: { error: 'model_mismatch', detail: e.message } }; }
319
+
320
+ // ── Contract mode is REPORT-FREE (chain-native phase 2 stage 3, #387) ──
321
+ // The verified DrawPaid event IS the record: the platform indexes the
322
+ // draw from MtokDripLedger logs, so there is nothing to report (the
323
+ // platform deleted POST /api/chunks/report in #487). The flow is
324
+ // verify => cap => claim => serve => complete, and the seller holds no platform
325
+ // secret. remainingUsd echoes what is left of THIS draw's paid amount
326
+ // after metering at the event's committed per-MTok prices.
327
+ const usage = completion.usage ?? {};
328
+ const inTok = Number(usage.prompt_tokens ?? usage.input_tokens ?? 0) || 0;
329
+ const outTok = Number(usage.completion_tokens ?? usage.output_tokens ?? 0) || 0;
330
+ // price is atomic USD per MTok (1e6 tokens): usd = tokens * priceAtomic / 1e12
331
+ const usedUsd = (inTok * Number(paidEvent.inputPricePerMTokAtomic || 0) + outTok * Number(paidEvent.outputPricePerMTokAtomic || 0)) / 1e12;
332
+ const payload = { ...completion, _bookingId: bookingId, remainingUsd: Math.max(0, Math.round((remainingUsd - usedUsd) * 1e6) / 1e6) };
333
+ try {
334
+ await redemption.complete(cacheKey, payload);
335
+ } catch (e) {
336
+ // The caller is already here and inference already ran: return its result.
337
+ // The durable pending claim remains authoritative, so every replay fails
338
+ // closed instead of spending upstream again.
339
+ (log ?? console).error?.(`mtok serve core: completion for ${cacheKey} could not be persisted (${e.message}); retries will remain pending`);
340
+ }
341
+ return { status: 200, body: payload };
342
+ };
343
+
344
+ return { serve };
345
+ }