mtok-bridge 0.2.0 → 0.3.1
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 +10 -0
- package/package.json +1 -1
- package/src/bridge.mjs +4 -0
- package/src/serve-core.mjs +361 -0
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.
|
|
3
|
+
"version": "0.3.1",
|
|
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,361 @@
|
|
|
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
|
+
// Tokenizer-independent input estimate, byte-aware with a safety margin.
|
|
123
|
+
//
|
|
124
|
+
// #626: this used to count one token per UTF-8 byte, i.e. a true worst-case
|
|
125
|
+
// bound (a tokenizer cannot emit more text tokens than bytes). That bound is
|
|
126
|
+
// correct and roughly 4x too pessimistic for real text, and the over-estimate
|
|
127
|
+
// is NOT free: boundServe refuses a draw whose estimated input cost alone meets
|
|
128
|
+
// the payment, and that refusal happens AFTER the buyer has paid on chain. A
|
|
129
|
+
// real buyer sending a ~4KB prompt on a budget that comfortably covered it was
|
|
130
|
+
// refused every night for two weeks and auto-disputed, silently.
|
|
131
|
+
//
|
|
132
|
+
// So estimate realistically and keep the margin explicit. BYTES_PER_TOKEN_EST
|
|
133
|
+
// of 3.2 is the English average (~4 bytes/token) with ~25% headroom, and
|
|
134
|
+
// staying in BYTES rather than characters keeps multibyte prompts from reading
|
|
135
|
+
// artificially cheap. The seller's residual exposure when an estimate lands
|
|
136
|
+
// low is bounded: actual usage is metered from the upstream response after the
|
|
137
|
+
// serve, and the output cap is computed from whatever budget the input
|
|
138
|
+
// estimate left, so an under-estimate eats into output headroom rather than
|
|
139
|
+
// running unpriced.
|
|
140
|
+
export const MESSAGE_OVERHEAD_TOKENS = 4;
|
|
141
|
+
export const BYTES_PER_TOKEN_EST = 3.2;
|
|
142
|
+
export function estimateInputTokens(messages) {
|
|
143
|
+
const utf8 = new TextEncoder();
|
|
144
|
+
let bytes = 0;
|
|
145
|
+
let envelope = 3; // reply priming
|
|
146
|
+
for (const m of messages ?? []) {
|
|
147
|
+
envelope += MESSAGE_OVERHEAD_TOKENS;
|
|
148
|
+
bytes += utf8.encode(String(m?.role ?? '')).length;
|
|
149
|
+
bytes += utf8.encode(typeof m?.content === 'string' ? m.content : JSON.stringify(m?.content ?? null)).length;
|
|
150
|
+
}
|
|
151
|
+
return envelope + Math.ceil(bytes / BYTES_PER_TOKEN_EST);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Bound a serve against the paid budget in BOTH legs (#495/#460). The relay used
|
|
155
|
+
// to cap only OUTPUT, so a dust draw + a huge prompt got its output capped but the
|
|
156
|
+
// whole prompt forwarded, making the seller eat unbounded upstream INPUT compute.
|
|
157
|
+
// Estimate the input cost and REFUSE before any upstream call if it alone meets or
|
|
158
|
+
// exceeds the payment; otherwise cap output over the budget LEFT after input. inPrice
|
|
159
|
+
// and outPrice are USD per MTok. The estimate gates the refuse ONLY; real billing
|
|
160
|
+
// still meters the upstream's reported token counts, so this never over-charges.
|
|
161
|
+
export function boundServe({ messages, budgetUsd, inPrice, outPrice, reqMax, contextCeil = 4096 }) {
|
|
162
|
+
const estIn = estimateInputTokens(messages);
|
|
163
|
+
const estInCostUsd = estIn * (Number(inPrice) > 0 ? Number(inPrice) : 0) / 1e6;
|
|
164
|
+
if (estInCostUsd >= budgetUsd) return { refuse: true, reason: 'input', estIn, estInCostUsd };
|
|
165
|
+
const outBudgetUsd = budgetUsd - estInCostUsd;
|
|
166
|
+
let maxTok = contextCeil;
|
|
167
|
+
if (Number(reqMax) > 0) maxTok = Math.min(maxTok, Math.floor(Number(reqMax)));
|
|
168
|
+
if (!Number.isFinite(Number(outPrice)) || Number(outPrice) <= 0) {
|
|
169
|
+
return { refuse: true, reason: 'output_price', estIn, estInCostUsd };
|
|
170
|
+
}
|
|
171
|
+
maxTok = Math.min(maxTok, Math.floor(outBudgetUsd / Number(outPrice) * 1e6));
|
|
172
|
+
if (maxTok < 1) return { refuse: true, reason: 'output', estIn, estInCostUsd };
|
|
173
|
+
return { refuse: false, maxTok, estIn, estInCostUsd };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The core factory. `verifier` is an mtok-verify createOnchainVerifier instance (or anything
|
|
177
|
+
// with the same verifyDrawPaid contract); `redemption` is the store interface documented at the
|
|
178
|
+
// top; `upstream(payload)` returns an OpenAI-shaped completion or throws (httpUpstream /
|
|
179
|
+
// workersAiUpstream from bridge.mjs both satisfy it); `screenPayer(payerLower)` is an optional
|
|
180
|
+
// async predicate the host composes (denylist, hook), truthy = refuse, throw = refuse (fail
|
|
181
|
+
// closed). serve(body) returns { status, body } for the host to serialize; the host owns
|
|
182
|
+
// per-booking serialization (locks) around it.
|
|
183
|
+
export function createServeCore({
|
|
184
|
+
model, inPrice, outPrice,
|
|
185
|
+
verifier, redemption, upstream, log,
|
|
186
|
+
offerId, sellerAgentId, sellerWallet,
|
|
187
|
+
dripContractAddress, feeRecipient, feeBps,
|
|
188
|
+
screenPayer,
|
|
189
|
+
}) {
|
|
190
|
+
const serve = async (body) => {
|
|
191
|
+
const { bookingId, n, buyerId, request, requestNonce, drawPaidTxHash } = body;
|
|
192
|
+
const hasRequestNonce = Object.hasOwn(body, 'requestNonce');
|
|
193
|
+
if (!bookingId) return { status: 400, body: { error: 'bad_request', detail: 'DRAW needs bookingId' } };
|
|
194
|
+
if (n == null) return { status: 400, body: { error: 'bad_request', detail: 'DRAW needs a delivery index n (per-booking idempotency key)' } };
|
|
195
|
+
if (!Number.isSafeInteger(n) || n < 0 || n > 0xffffffff) {
|
|
196
|
+
return { status: 400, body: { error: 'bad_request', detail: 'DRAW delivery index n must be a nonnegative uint32 integer' } };
|
|
197
|
+
}
|
|
198
|
+
if (hasRequestNonce && !REQUEST_NONCE_RE.test(requestNonce)) {
|
|
199
|
+
return { status: 400, body: { error: 'bad_request', detail: 'DRAW needs requestNonce as 16 random bytes encoded as 0x-prefixed hex' } };
|
|
200
|
+
}
|
|
201
|
+
const checked = validateRequest(request, model, { legacy: !hasRequestNonce });
|
|
202
|
+
if (checked.error) return { status: 400, body: { error: 'bad_request', detail: checked.error } };
|
|
203
|
+
|
|
204
|
+
// Legacy SDKs paid for sha256(JSON.stringify(request)) and sent no nonce.
|
|
205
|
+
// Keep those already-paid draws redeemable while current offers advertise
|
|
206
|
+
// nonce-v1 so current SDKs can require the private commitment before paying.
|
|
207
|
+
const requestHashScheme = hasRequestNonce ? 'nonce-v1' : 'legacy-v0';
|
|
208
|
+
const requestHash = hasRequestNonce ? await hash32({ request, requestNonce }) : await hash32(request);
|
|
209
|
+
// Prefix the commitment scheme so legacy and nonce-v1 entries cannot alias.
|
|
210
|
+
// nonce-v1 binds the request to a buyer-held random nonce, preventing a chain
|
|
211
|
+
// observer from guessing a common prompt and deriving its completion key.
|
|
212
|
+
// Legacy redemption remains only to honor draws already paid by old SDKs.
|
|
213
|
+
const cacheKey = `${requestHashScheme}:${bookingId}:${n}:${requestHash}`;
|
|
214
|
+
const oldLegacyKey = hasRequestNonce ? null : `${bookingId}:${n}:${requestHash}`;
|
|
215
|
+
|
|
216
|
+
// Contract mode is the ONLY mode (#487): the legacy direct-transfer FUND
|
|
217
|
+
// lane and its /api/bookings/:id balance read are gone. If the platform is
|
|
218
|
+
// not running the drip contract, REFUSE the draw with a clear error rather
|
|
219
|
+
// than fall back to a lane that no longer exists.
|
|
220
|
+
if (!dripContractAddress) {
|
|
221
|
+
return { status: 402, body: { error: 'contract_mode_required', detail: 'this relay only serves contract-mode draws; the platform is not reporting a dripContractAddress' } };
|
|
222
|
+
}
|
|
223
|
+
if (!drawPaidTxHash) return { status: 402, body: { error: 'draw_payment_required', detail: 'contract mode requires drawPaidTxHash before upstream delivery' } };
|
|
224
|
+
let paid;
|
|
225
|
+
try {
|
|
226
|
+
paid = await verifier.verifyDrawPaid(drawPaidTxHash, {
|
|
227
|
+
contractAddress: dripContractAddress,
|
|
228
|
+
buyerAgentId: buyerId,
|
|
229
|
+
sellerAgentId, // when set, enforces the offer-owner match (#codex review)
|
|
230
|
+
bookingId,
|
|
231
|
+
offerId,
|
|
232
|
+
model,
|
|
233
|
+
n,
|
|
234
|
+
requestHash,
|
|
235
|
+
sellerWallet,
|
|
236
|
+
feeRecipient,
|
|
237
|
+
// #580: refuse a payment older than the redemption window. The JSONL
|
|
238
|
+
// payload cache AND the claim markers are both aged out at boot (#600),
|
|
239
|
+
// so past retention this age bound is the sole replay defense (its
|
|
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,
|
|
243
|
+
});
|
|
244
|
+
} catch (e) {
|
|
245
|
+
return { status: 402, body: { error: 'payment_unverified', detail: e.message } };
|
|
246
|
+
}
|
|
247
|
+
if (!paid?.ok) return { status: 402, body: { error: 'payment_unverified', detail: paid?.reason || 'unknown' } };
|
|
248
|
+
const expectedFee = configuredFeeAtomic({
|
|
249
|
+
sellerUsdAtomic: paid.event.sellerUsdAtomic,
|
|
250
|
+
feeAddress: feeRecipient,
|
|
251
|
+
feeBps,
|
|
252
|
+
});
|
|
253
|
+
if (BigInt(paid.event.feeUsdAtomic || 0) < expectedFee) {
|
|
254
|
+
return { status: 402, body: { error: 'payment_unverified', detail: 'fee_amount_too_low' } };
|
|
255
|
+
}
|
|
256
|
+
// Screen the verified payer before spending upstream capacity. The
|
|
257
|
+
// payment already settled on-chain (that money is the buyer's loss);
|
|
258
|
+
// this refuses the SERVICE, which is the only refusal an edge can
|
|
259
|
+
// still make in contract mode.
|
|
260
|
+
if (screenPayer) {
|
|
261
|
+
try {
|
|
262
|
+
if (await screenPayer(String(paid.from || '').toLowerCase())) {
|
|
263
|
+
return { status: 403, body: { error: 'payer_denied', detail: 'the verified payer wallet is denylisted by this relay' } };
|
|
264
|
+
}
|
|
265
|
+
} catch (e) {
|
|
266
|
+
// A broken screen hook fails CLOSED: do not serve on an unscreenable payer.
|
|
267
|
+
return { status: 403, body: { error: 'payer_denied', detail: 'payer screening failed: ' + e.message } };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
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
|
+
if (redemptionState === 'complete') return { status: 200, body: await redemption.get(storedKey) };
|
|
289
|
+
if (redemptionState === 'pending') {
|
|
290
|
+
return { status: 409, body: { error: 'draw_pending', detail: 'this paid draw was already claimed; refusing to run upstream again', _bookingId: bookingId } };
|
|
291
|
+
}
|
|
292
|
+
const paidEvent = paid.event;
|
|
293
|
+
const remainingUsd = Number(paid.event.sellerUsdAtomic || 0) / 1e6;
|
|
294
|
+
if (remainingUsd < BALANCE_EPSILON) {
|
|
295
|
+
return { status: 402, body: { error: 'balance_exhausted', detail: `remainingUsd=${remainingUsd}`, _bookingId: bookingId, remainingUsd } };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Bound BOTH legs against what the draw paid for (#495/#460). The relay used
|
|
299
|
+
// to cap only OUTPUT, so a dust draw + a huge prompt got its output capped but
|
|
300
|
+
// the whole prompt forwarded => the seller ate unbounded upstream INPUT compute.
|
|
301
|
+
// Now: REFUSE before any upstream call if the estimated input cost alone meets
|
|
302
|
+
// the payment, else cap output over the budget LEFT after input. Input is priced
|
|
303
|
+
// at the higher of our offer price and the buyer's committed event price, so the
|
|
304
|
+
// buyer can't zero the input leg to sneak a big prompt.
|
|
305
|
+
const eventInPriceUsd = Number(paidEvent.inputPricePerMTokAtomic || 0) / 1e6;
|
|
306
|
+
const eventOutPriceUsd = Number(paidEvent.outputPricePerMTokAtomic || 0) / 1e6;
|
|
307
|
+
const boundInPrice = Math.max(Number(inPrice) || 0, eventInPriceUsd);
|
|
308
|
+
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 });
|
|
310
|
+
if (bound.refuse) {
|
|
311
|
+
const error = bound.reason === 'input' ? 'input_too_large' : 'output_unfunded';
|
|
312
|
+
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 } };
|
|
313
|
+
}
|
|
314
|
+
const safeRequest = { ...checked.safeRequest, model, max_tokens: bound.maxTok };
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
// Legacy uses its old unprefixed identity only for the atomic marker so
|
|
318
|
+
// parallel upgraded stores and an existing log converge on one claim.
|
|
319
|
+
if (!(await redemption.claim(cacheKey, oldLegacyKey ?? cacheKey))) {
|
|
320
|
+
return { status: 409, body: { error: 'draw_pending', detail: 'this paid draw was already claimed; refusing to run upstream again', _bookingId: bookingId } };
|
|
321
|
+
}
|
|
322
|
+
} catch (e) {
|
|
323
|
+
return { status: 503, body: { error: 'redemption_unavailable', detail: `could not durably claim the paid draw: ${e.message}`, _bookingId: bookingId } };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let completion;
|
|
327
|
+
try {
|
|
328
|
+
completion = await upstream(safeRequest);
|
|
329
|
+
} catch (e) {
|
|
330
|
+
return { status: 502, body: { error: 'upstream_error', detail: e.message } };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
try { enforceModelEcho(completion.model, model); }
|
|
334
|
+
catch (e) { return { status: 502, body: { error: 'model_mismatch', detail: e.message } }; }
|
|
335
|
+
|
|
336
|
+
// ── Contract mode is REPORT-FREE (chain-native phase 2 stage 3, #387) ──
|
|
337
|
+
// The verified DrawPaid event IS the record: the platform indexes the
|
|
338
|
+
// draw from MtokDripLedger logs, so there is nothing to report (the
|
|
339
|
+
// platform deleted POST /api/chunks/report in #487). The flow is
|
|
340
|
+
// verify => cap => claim => serve => complete, and the seller holds no platform
|
|
341
|
+
// secret. remainingUsd echoes what is left of THIS draw's paid amount
|
|
342
|
+
// after metering at the event's committed per-MTok prices.
|
|
343
|
+
const usage = completion.usage ?? {};
|
|
344
|
+
const inTok = Number(usage.prompt_tokens ?? usage.input_tokens ?? 0) || 0;
|
|
345
|
+
const outTok = Number(usage.completion_tokens ?? usage.output_tokens ?? 0) || 0;
|
|
346
|
+
// price is atomic USD per MTok (1e6 tokens): usd = tokens * priceAtomic / 1e12
|
|
347
|
+
const usedUsd = (inTok * Number(paidEvent.inputPricePerMTokAtomic || 0) + outTok * Number(paidEvent.outputPricePerMTokAtomic || 0)) / 1e12;
|
|
348
|
+
const payload = { ...completion, _bookingId: bookingId, remainingUsd: Math.max(0, Math.round((remainingUsd - usedUsd) * 1e6) / 1e6) };
|
|
349
|
+
try {
|
|
350
|
+
await redemption.complete(cacheKey, payload);
|
|
351
|
+
} catch (e) {
|
|
352
|
+
// The caller is already here and inference already ran: return its result.
|
|
353
|
+
// The durable pending claim remains authoritative, so every replay fails
|
|
354
|
+
// closed instead of spending upstream again.
|
|
355
|
+
(log ?? console).error?.(`mtok serve core: completion for ${cacheKey} could not be persisted (${e.message}); retries will remain pending`);
|
|
356
|
+
}
|
|
357
|
+
return { status: 200, body: payload };
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
return { serve };
|
|
361
|
+
}
|