mtok-bridge 0.3.4 → 0.4.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 +38 -1
- package/package.json +1 -1
- package/src/bridge.mjs +27 -0
- package/src/serve-core.mjs +92 -64
package/README.md
CHANGED
|
@@ -41,10 +41,47 @@ tool, one layer on top.
|
|
|
41
41
|
(and the workers-ai house seller) run: validate request => verify the on-chain DrawPaid
|
|
42
42
|
(request-hash bound) => bound both legs against the payment => claim => upstream => complete,
|
|
43
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)
|
|
44
|
+
retentionMs }`, each method sync or async: the core awaits every call). The store must make
|
|
45
|
+
claims atomic across every host serving that offer. Independent local files and eventually
|
|
46
|
+
consistent KV read/write pairs do not provide that guarantee. If you
|
|
45
47
|
are just serving a model for a key, you never need it; it is here so every mtok seller host
|
|
46
48
|
shares one money path.
|
|
47
49
|
|
|
50
|
+
`state(key, { claimKey })` and `get(key, { claimKey })` receive the canonical commitment
|
|
51
|
+
identity even when `key` names a legacy record. `claim(key, markerKey, { paidAtMs })` receives
|
|
52
|
+
the verifier's payment timestamp, so a store migrating old claims can refuse ambiguous
|
|
53
|
+
pre-cutover payments. Existing stores may ignore these additional arguments. The core still
|
|
54
|
+
verifies the payment before reading a completion, and known claims never run upstream again.
|
|
55
|
+
|
|
56
|
+
The counted-input change is not ready to publish. The reference HTTP relay and SDK
|
|
57
|
+
quote flow are integrated; the house seller's provider counters and rollout are still pending.
|
|
58
|
+
|
|
59
|
+
The new core requires `countInputTokens(safeRequest)`, returning a nonnegative safe integer
|
|
60
|
+
or a promise for one. The host must count its actual provider prompt, including its chat
|
|
61
|
+
template and special tokens. A missing, failed or invalid counter returns 503 before claiming
|
|
62
|
+
a fresh draw. Completed and pending retries retain their existing outcomes without counting
|
|
63
|
+
again. The old byte-estimate export is retained for compatibility; neither the core nor
|
|
64
|
+
the current SDK uses it to authorize spending.
|
|
65
|
+
|
|
66
|
+
`core.quote(request)` validates the same request and returns `{ status, body }` with
|
|
67
|
+
`model`, `offerId`, `inputTokens`, `maxInputTokens`, `maxOutputTokens`, and the decimal-string
|
|
68
|
+
prices `inputPricePerMTokAtomic` / `outputPricePerMTokAtomic`. It performs no payment
|
|
69
|
+
verification, claim or inference. Expose it at `POST /quote` with `{ request }` under the
|
|
70
|
+
same request-size, concurrency and rate limits as `/chunk`. The paid path recounts before
|
|
71
|
+
claiming; a quote does not override payment verification or lock provider configuration.
|
|
72
|
+
|
|
73
|
+
For a vLLM-compatible provider, `httpInputCounter({ url, key })` calls its native `/tokenize`
|
|
74
|
+
chat endpoint with the same model and messages, `add_generation_prompt:true`, and
|
|
75
|
+
`add_special_tokens:false`. The provider must render exactly the chat template used by
|
|
76
|
+
inference. Unsupported providers need their own counter; there is no character-estimate
|
|
77
|
+
fallback. The tokenization request has a five-second deadline and a bounded response body.
|
|
78
|
+
|
|
79
|
+
`maxInputTokens` sets the hard input ceiling (default 131072). `maxOutputTokens` retains its
|
|
80
|
+
32768 default. `boundServe` now takes `inputTokens`, `budgetUsdAtomic`, `inPriceAtomic` and
|
|
81
|
+
`outPriceAtomic`. Prices are atomic USD per million tokens. It reserves input in integer
|
|
82
|
+
arithmetic and funds output only from the remainder. The core uses the higher of the host's
|
|
83
|
+
price and the verified payment's committed price for each leg.
|
|
84
|
+
|
|
48
85
|
|
|
49
86
|
---
|
|
50
87
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mtok-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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
|
@@ -75,6 +75,33 @@ export function httpUpstream({ baseUrl, key, timeoutMs }) {
|
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// The provider owns the chat template. Its tokenization endpoint must use the
|
|
79
|
+
// same model and renderer as /chat/completions; a character estimate cannot
|
|
80
|
+
// replace this call. This is the vLLM TokenizeChatRequest contract.
|
|
81
|
+
export function httpInputCounter({ url, key, timeoutMs = 5000 }) {
|
|
82
|
+
return async ({ model, messages }) => {
|
|
83
|
+
const response = await fetch(url, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: { 'content-type': 'application/json', ...(key ? { authorization: `Bearer ${key}` } : {}) },
|
|
86
|
+
body: JSON.stringify({ model, messages, add_generation_prompt: true, add_special_tokens: false }),
|
|
87
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
88
|
+
});
|
|
89
|
+
if (!response.ok) throw new Error(`upstream tokenization ${response.status}`);
|
|
90
|
+
// Native tokenizers also return every token ID. Bound that response before
|
|
91
|
+
// parsing, while allowing a full token array for the relay's 256 KB input.
|
|
92
|
+
const decoder = new TextDecoder();
|
|
93
|
+
const chunks = [];
|
|
94
|
+
let size = 0;
|
|
95
|
+
for await (const chunk of response.body) {
|
|
96
|
+
size += chunk.byteLength;
|
|
97
|
+
if (size > 4 * 1024 * 1024) throw new Error('upstream tokenization response is too large');
|
|
98
|
+
chunks.push(decoder.decode(chunk, { stream: true }));
|
|
99
|
+
}
|
|
100
|
+
chunks.push(decoder.decode());
|
|
101
|
+
return JSON.parse(chunks.join('')).count;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
78
105
|
// The SECOND upstream mode (#566): a Cloudflare Workers AI binding instead of an HTTP endpoint.
|
|
79
106
|
// `ai` is the Worker's `env.AI` (has `.run(model, { messages, max_tokens })`). Returns the same
|
|
80
107
|
// upstream(payload) contract as httpUpstream, normalizing Workers AI's output (native `{ response,
|
package/src/serve-core.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// payer-screen POLICY stay in the host.
|
|
9
9
|
//
|
|
10
10
|
// The redemption dependency is an INTERFACE the caller passes:
|
|
11
|
-
// { state(key), get(key), claim(key, markerKey), complete(key, payload), retentionMs }
|
|
11
|
+
// { state(key, { claimKey }), get(key, { claimKey }), claim(key, markerKey, { paidAtMs }), complete(key, payload), retentionMs }
|
|
12
12
|
// state(key) returns 'pending' | 'complete' | null. claim(key, markerKey) returns false when the
|
|
13
13
|
// draw is already claimed and THROWS when it cannot claim durably (the core then refuses before
|
|
14
14
|
// upstream spend). complete(key, payload) throws when the payload cannot be persisted; the claim
|
|
@@ -161,24 +161,8 @@ export function configuredFeeAtomic({ sellerUsdAtomic, feeAddress, feeBps }) {
|
|
|
161
161
|
return (BigInt(sellerUsdAtomic || 0) * bps + 5000n) / 10000n;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
//
|
|
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.
|
|
164
|
+
// Legacy buyer estimate. It is not an upper bound and must never authorize
|
|
165
|
+
// inference: digit-dense prompts can consume more than three times this count.
|
|
182
166
|
export const MESSAGE_OVERHEAD_TOKENS = 4;
|
|
183
167
|
export const BYTES_PER_TOKEN_EST = 3.2;
|
|
184
168
|
export function estimateInputTokens(messages) {
|
|
@@ -193,33 +177,37 @@ export function estimateInputTokens(messages) {
|
|
|
193
177
|
return envelope + Math.ceil(bytes / BYTES_PER_TOKEN_EST);
|
|
194
178
|
}
|
|
195
179
|
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
// Estimate the input cost and REFUSE before any upstream call if it alone meets or
|
|
200
|
-
// exceeds the payment; otherwise cap output over the budget LEFT after input. inPrice
|
|
201
|
-
// and outPrice are USD per MTok. The estimate gates the refuse ONLY; real billing
|
|
202
|
-
// still meters the upstream's reported token counts, so this never over-charges.
|
|
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.
|
|
180
|
+
// Reserve the counted input before output. Prices are atomic USD per million
|
|
181
|
+
// tokens; keeping that denominator until division avoids rounding a fractional
|
|
182
|
+
// input charge away or spending one atomic unit twice. Billing still uses usage.
|
|
209
183
|
export const DEFAULT_MAX_OUTPUT_TOKENS = 32768;
|
|
210
|
-
export
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (
|
|
214
|
-
|
|
184
|
+
export const DEFAULT_MAX_INPUT_TOKENS = 131072;
|
|
185
|
+
function withinInputLimit(inputTokens, inputCeil) {
|
|
186
|
+
if (!Number.isSafeInteger(inputTokens) || inputTokens < 0) throw new TypeError('input token count must be a nonnegative safe integer');
|
|
187
|
+
if (!Number.isSafeInteger(inputCeil) || inputCeil <= 0) throw new TypeError('input token ceiling must be a positive safe integer');
|
|
188
|
+
return inputTokens <= inputCeil;
|
|
189
|
+
}
|
|
190
|
+
const offerPriceAtomic = value => BigInt(Math.ceil(Number(value) * 1e6));
|
|
191
|
+
|
|
192
|
+
export function boundServe({ inputTokens, budgetUsdAtomic, inPriceAtomic, outPriceAtomic, reqMax, inputCeil = DEFAULT_MAX_INPUT_TOKENS, contextCeil = DEFAULT_MAX_OUTPUT_TOKENS }) {
|
|
193
|
+
const inputAllowed = withinInputLimit(inputTokens, inputCeil);
|
|
194
|
+
if (!Number.isSafeInteger(contextCeil) || contextCeil <= 0) throw new TypeError('output token ceiling must be a positive safe integer');
|
|
195
|
+
const budget = BigInt(budgetUsdAtomic);
|
|
196
|
+
const inputPrice = BigInt(inPriceAtomic);
|
|
197
|
+
const outputPrice = BigInt(outPriceAtomic);
|
|
198
|
+
if (budget < 0n || inputPrice < 0n || outputPrice < 0n) throw new RangeError('token budget and prices must be nonnegative');
|
|
199
|
+
const inputCost = BigInt(inputTokens) * inputPrice;
|
|
200
|
+
const details = { inputTokens, inputCostUsd: Number(inputCost) / 1e12 };
|
|
201
|
+
if (!inputAllowed) return { refuse: true, reason: 'input_limit', ...details };
|
|
202
|
+
const outputBudget = budget * 1_000_000n - inputCost;
|
|
203
|
+
if (outputBudget <= 0n) return { refuse: true, reason: 'input', ...details };
|
|
204
|
+
if (outputPrice === 0n) return { refuse: true, reason: 'output_price', ...details };
|
|
215
205
|
let maxTok = contextCeil;
|
|
216
206
|
if (Number(reqMax) > 0) maxTok = Math.min(maxTok, Math.floor(Number(reqMax)));
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
if (maxTok < 1) return { refuse: true, reason: 'output', estIn, estInCostUsd };
|
|
222
|
-
return { refuse: false, maxTok, estIn, estInCostUsd };
|
|
207
|
+
const fundedOutput = outputBudget / outputPrice;
|
|
208
|
+
if (fundedOutput < BigInt(maxTok)) maxTok = Number(fundedOutput);
|
|
209
|
+
if (maxTok < 1) return { refuse: true, reason: 'output', ...details };
|
|
210
|
+
return { refuse: false, maxTok, ...details };
|
|
223
211
|
}
|
|
224
212
|
|
|
225
213
|
// The core factory. `verifier` is an mtok-verify createOnchainVerifier instance (or anything
|
|
@@ -235,13 +223,37 @@ export function createServeCore({
|
|
|
235
223
|
offerId, sellerAgentId, sellerWallet,
|
|
236
224
|
dripContractAddress, feeRecipient, feeBps,
|
|
237
225
|
screenPayer,
|
|
226
|
+
// The host counts its actual sanitized provider input. Missing or failed
|
|
227
|
+
// accounting refuses a fresh claim; saved completions do not need recounting.
|
|
228
|
+
countInputTokens,
|
|
229
|
+
maxInputTokens = DEFAULT_MAX_INPUT_TOKENS,
|
|
238
230
|
// #654: the output-token sanity ceiling for boundServe. The PAID budget already
|
|
239
231
|
// bounds output (and metering is on real usage), so this is a defensive cap on a
|
|
240
232
|
// single generation, not a money guard. It is a per-relay knob: the reference host
|
|
241
233
|
// passes MTOK_MAX_OUTPUT_TOKENS / a config value; operators serving large-context
|
|
242
234
|
// models raise it. Falls back to boundServe's own generous default when unset.
|
|
243
|
-
maxOutputTokens,
|
|
235
|
+
maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS,
|
|
244
236
|
}) {
|
|
237
|
+
const quote = async (request) => {
|
|
238
|
+
const checked = validateRequest(request, model);
|
|
239
|
+
if (checked.error) return { status: 400, body: { error: 'bad_request', detail: checked.error } };
|
|
240
|
+
try {
|
|
241
|
+
const inputTokens = await countInputTokens(checked.safeRequest);
|
|
242
|
+
if (!withinInputLimit(inputTokens, maxInputTokens)) return { status: 413, body: { error: 'input_too_large', detail: `input (${inputTokens} tokens) exceeds this relay's input-token limit` } };
|
|
243
|
+
if (!Number.isSafeInteger(maxOutputTokens) || maxOutputTokens <= 0) throw new TypeError('output token ceiling must be a positive safe integer');
|
|
244
|
+
const inputPrice = offerPriceAtomic(inPrice);
|
|
245
|
+
const outputPrice = offerPriceAtomic(outPrice);
|
|
246
|
+
if (inputPrice < 0n || outputPrice <= 0n) throw new RangeError('invalid offer prices');
|
|
247
|
+
return { status: 200, body: {
|
|
248
|
+
model, offerId, inputTokens, maxInputTokens, maxOutputTokens,
|
|
249
|
+
inputPricePerMTokAtomic: inputPrice.toString(),
|
|
250
|
+
outputPricePerMTokAtomic: outputPrice.toString(),
|
|
251
|
+
} };
|
|
252
|
+
} catch (e) {
|
|
253
|
+
return { status: 503, body: { error: 'input_accounting_unavailable', detail: e.message } };
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
245
257
|
const serve = async (body) => {
|
|
246
258
|
const currentFeeRecipient = typeof feeRecipient === 'function' ? feeRecipient() : feeRecipient;
|
|
247
259
|
const { bookingId, n, buyerId, request, requestNonce, drawPaidTxHash } = body;
|
|
@@ -268,6 +280,7 @@ export function createServeCore({
|
|
|
268
280
|
// Legacy redemption remains only to honor draws already paid by old SDKs.
|
|
269
281
|
const cacheKey = `${requestHashScheme}:${bookingId}:${n}:${requestHash}`;
|
|
270
282
|
const oldLegacyKey = hasRequestNonce ? null : `${bookingId}:${n}:${requestHash}`;
|
|
283
|
+
const redemptionContext = { claimKey: cacheKey };
|
|
271
284
|
|
|
272
285
|
// Contract mode is the ONLY mode (#487): the legacy direct-transfer FUND
|
|
273
286
|
// lane and its /api/bookings/:id balance read are gone. If the platform is
|
|
@@ -281,18 +294,18 @@ export function createServeCore({
|
|
|
281
294
|
// The redemption interface may be sync (fs store) or async (a Workers KV
|
|
282
295
|
// store): await normalizes both, and identity-awaits cost nothing under the
|
|
283
296
|
// host's per-booking lock.
|
|
284
|
-
let redemptionState = await redemption.state(storedKey);
|
|
297
|
+
let redemptionState = await redemption.state(storedKey, redemptionContext);
|
|
285
298
|
// Preserve an upgrade's pre-scheme redemption log; missing this alias
|
|
286
299
|
// would let a legacy paid draw run upstream again after relay upgrade.
|
|
287
300
|
if (!redemptionState && oldLegacyKey) {
|
|
288
|
-
const oldLegacyState = await redemption.state(oldLegacyKey);
|
|
301
|
+
const oldLegacyState = await redemption.state(oldLegacyKey, redemptionContext);
|
|
289
302
|
if (oldLegacyState) {
|
|
290
303
|
storedKey = oldLegacyKey;
|
|
291
304
|
redemptionState = oldLegacyState;
|
|
292
305
|
} else {
|
|
293
306
|
// Checking the legacy marker may have refreshed a prefixed record
|
|
294
307
|
// appended by another upgraded process.
|
|
295
|
-
redemptionState = await redemption.state(cacheKey);
|
|
308
|
+
redemptionState = await redemption.state(cacheKey, redemptionContext);
|
|
296
309
|
}
|
|
297
310
|
}
|
|
298
311
|
let paid;
|
|
@@ -332,7 +345,11 @@ export function createServeCore({
|
|
|
332
345
|
return { status: 403, body: { error: 'payer_denied', detail: 'payer screening failed: ' + e.message } };
|
|
333
346
|
}
|
|
334
347
|
}
|
|
335
|
-
if (redemptionState === 'complete')
|
|
348
|
+
if (redemptionState === 'complete') {
|
|
349
|
+
const payload = await redemption.get(storedKey, redemptionContext);
|
|
350
|
+
if (payload == null) return { status: 503, body: { error: 'redemption_unavailable', detail: 'saved completion is no longer readable; retry this same paid draw', _bookingId: bookingId } };
|
|
351
|
+
return { status: 200, body: payload };
|
|
352
|
+
}
|
|
336
353
|
if (redemptionState === 'pending') {
|
|
337
354
|
return { status: 409, body: { error: 'draw_pending', detail: 'this paid draw was already claimed; refusing to run upstream again', _bookingId: bookingId } };
|
|
338
355
|
}
|
|
@@ -355,28 +372,39 @@ export function createServeCore({
|
|
|
355
372
|
return { status: 402, body: { error: 'balance_exhausted', detail: `remainingUsd=${remainingUsd}`, _bookingId: bookingId, remainingUsd } };
|
|
356
373
|
}
|
|
357
374
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
375
|
+
let bound;
|
|
376
|
+
try {
|
|
377
|
+
const inputTokens = await countInputTokens(checked.safeRequest);
|
|
378
|
+
const price = (offered, committed) => {
|
|
379
|
+
const local = offerPriceAtomic(offered);
|
|
380
|
+
const paid = BigInt(committed);
|
|
381
|
+
return local > paid ? local : paid;
|
|
382
|
+
};
|
|
383
|
+
bound = boundServe({
|
|
384
|
+
inputTokens,
|
|
385
|
+
budgetUsdAtomic: paidEvent.sellerUsdAtomic,
|
|
386
|
+
inPriceAtomic: price(inPrice, paidEvent.inputPricePerMTokAtomic),
|
|
387
|
+
outPriceAtomic: price(outPrice, paidEvent.outputPricePerMTokAtomic),
|
|
388
|
+
reqMax: checked.safeRequest.max_tokens,
|
|
389
|
+
inputCeil: maxInputTokens,
|
|
390
|
+
contextCeil: maxOutputTokens,
|
|
391
|
+
});
|
|
392
|
+
} catch (e) {
|
|
393
|
+
return { status: 503, body: { error: 'input_accounting_unavailable', detail: e.message, _bookingId: bookingId } };
|
|
394
|
+
}
|
|
370
395
|
if (bound.refuse) {
|
|
371
|
-
const error = bound.reason === 'input' ? 'input_too_large' : 'output_unfunded';
|
|
372
|
-
|
|
396
|
+
const error = bound.reason === 'input' || bound.reason === 'input_limit' ? 'input_too_large' : 'output_unfunded';
|
|
397
|
+
const detail = bound.reason === 'input_limit'
|
|
398
|
+
? `input (${bound.inputTokens} tokens) exceeds this relay's input-token limit`
|
|
399
|
+
: `input (${bound.inputTokens} tokens, $${bound.inputCostUsd.toFixed(6)}) leaves no safely funded output in the paid amount ($${remainingUsd})`;
|
|
400
|
+
return { status: 402, body: { error, detail, _bookingId: bookingId, remainingUsd } };
|
|
373
401
|
}
|
|
374
402
|
const safeRequest = { ...checked.safeRequest, model, max_tokens: bound.maxTok };
|
|
375
403
|
|
|
376
404
|
try {
|
|
377
405
|
// Legacy uses its old unprefixed identity only for the atomic marker so
|
|
378
406
|
// parallel upgraded stores and an existing log converge on one claim.
|
|
379
|
-
if (!(await redemption.claim(cacheKey, oldLegacyKey ?? cacheKey))) {
|
|
407
|
+
if (!(await redemption.claim(cacheKey, oldLegacyKey ?? cacheKey, { paidAtMs: paid.paidAtMs }))) {
|
|
380
408
|
return { status: 409, body: { error: 'draw_pending', detail: 'this paid draw was already claimed; refusing to run upstream again', _bookingId: bookingId } };
|
|
381
409
|
}
|
|
382
410
|
} catch (e) {
|
|
@@ -423,5 +451,5 @@ export function createServeCore({
|
|
|
423
451
|
return { status: 200, body: payload };
|
|
424
452
|
};
|
|
425
453
|
|
|
426
|
-
return { serve };
|
|
454
|
+
return { serve, quote };
|
|
427
455
|
}
|