Compare commits
10 Commits
fix/escrow
...
feat/ocr-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d348e762f | ||
|
|
7bf3252cdb | ||
|
|
feabd12876 | ||
|
|
2b2295b7ed | ||
|
|
069edcb076 | ||
|
|
d5349057b0 | ||
|
|
678ee862ea | ||
|
|
6ca7f2927a | ||
|
|
4d94aa5040 | ||
|
|
36c5cfc6be |
@@ -35,6 +35,8 @@ RUN apk add --no-cache python3 make g++
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
|
||||
# pnpm-lock.yaml's patchedDependencies hash the patch files at install time.
|
||||
COPY patches ./patches
|
||||
COPY packages/shared/package.json ./packages/shared/package.json
|
||||
COPY buyer/package.json ./buyer/package.json
|
||||
COPY supplier/package.json ./supplier/package.json
|
||||
|
||||
@@ -73,8 +73,10 @@ import {
|
||||
buildPostEscrowTx,
|
||||
buildPostTtsEscrowTx,
|
||||
buildPostChatEscrowTx,
|
||||
buildPostOcrEscrowTx,
|
||||
ttsPromptHash,
|
||||
chatSessionPromptHash,
|
||||
ocrPromptHash,
|
||||
buildAcceptTx,
|
||||
buildReclaimTx,
|
||||
TxConstructionError,
|
||||
@@ -88,6 +90,8 @@ import type {
|
||||
SubmitPromptResult,
|
||||
SubmitTtsOptions,
|
||||
SubmitTtsResult,
|
||||
SubmitOcrOptions,
|
||||
SubmitOcrResult,
|
||||
StartChatOptions,
|
||||
StartChatResult,
|
||||
ChatSettleMode,
|
||||
@@ -807,6 +811,278 @@ export class Marketplace extends EventEmitterBase {
|
||||
};
|
||||
}
|
||||
|
||||
// ─── submitOcr — full lifecycle for ocr.page.extract.<model-slug>.v1 ──
|
||||
|
||||
async submitOcr(opts: SubmitOcrOptions): Promise<SubmitOcrResult> {
|
||||
const { advertRef, image_b64, mime, output_format, payment_lovelace } = opts;
|
||||
const request = { image_b64, mime, output_format };
|
||||
|
||||
let advertDatum: AdvertDatum | null = null;
|
||||
let escrowOutputRef: OutputReference | null = null;
|
||||
let escrowRefStr = "";
|
||||
let postedAtMs = Date.now();
|
||||
|
||||
const previewStr = `[ocr:${mime} ${output_format} ${image_b64.length}b64]`;
|
||||
const recordFailure = (reason: string): void => {
|
||||
this.historyStore.save({
|
||||
escrow_ref: escrowRefStr || `${"0".repeat(64)}#0`,
|
||||
supplier_pkh: advertDatum?.supplier_pkh ?? "",
|
||||
capability_id: advertDatum?.capability_id ?? "",
|
||||
prompt_preview: previewStr,
|
||||
posted_at: postedAtMs,
|
||||
status: "failed",
|
||||
failure_reason: reason,
|
||||
});
|
||||
};
|
||||
|
||||
// ── 1. Resolve advert + post escrow (OCR prompt_hash) ─────────────
|
||||
let escrowResult;
|
||||
try {
|
||||
const utxo = await this.chain.queryUtxo(advertRef);
|
||||
if (utxo && utxo.datumHex) {
|
||||
try {
|
||||
advertDatum = decodeAdvertDatum(utxo.datumHex);
|
||||
} catch { /* builder will re-throw structurally */ }
|
||||
}
|
||||
escrowResult = await buildPostOcrEscrowTx({
|
||||
chain: this.chain,
|
||||
buyerKey: this.walletKey,
|
||||
advertRef,
|
||||
request,
|
||||
payment_lovelace,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof TxConstructionError) {
|
||||
recordFailure(err.reason);
|
||||
throw err;
|
||||
}
|
||||
this.emitProgress({ type: "chain_submit_failed", detail: (err as Error).message });
|
||||
recordFailure((err as Error).message);
|
||||
throw err;
|
||||
}
|
||||
|
||||
escrowOutputRef = escrowResult.escrowOutputRef;
|
||||
escrowRefStr = refToString(escrowOutputRef);
|
||||
|
||||
this.emitProgress({ type: "escrow_posted", escrow_ref: escrowRefStr });
|
||||
|
||||
// ── 2. Wait for the escrow tx to confirm ──────────────────────────
|
||||
try {
|
||||
await this.chain.awaitTx(escrowResult.expectedTxHash, 120_000);
|
||||
} catch { /* mock providers / tests */ }
|
||||
|
||||
// ── 3. Re-fetch escrow datum to capture posted_at ─────────────────
|
||||
try {
|
||||
const escrowUtxo = await this.chain.queryUtxo(escrowOutputRef);
|
||||
if (escrowUtxo && escrowUtxo.datumHex) {
|
||||
const ed = decodeEscrowDatum(escrowUtxo.datumHex);
|
||||
postedAtMs = ed.posted_at;
|
||||
}
|
||||
} catch { /* metadata best-effort */ }
|
||||
|
||||
if (!advertDatum) {
|
||||
const reason = "advert datum unavailable after escrow post";
|
||||
recordFailure(reason);
|
||||
throw new ReceiptVerificationError(reason);
|
||||
}
|
||||
|
||||
// ── 4. Call supplier /v1/ocr/extract ──────────────────────────────
|
||||
const supplierBaseUrl = advertDatum.endpoint_url;
|
||||
const supplierHttp = new HttpClient({
|
||||
baseUrl: supplierBaseUrl,
|
||||
fetch: this.fetchImpl,
|
||||
});
|
||||
const ocrBody = { image_b64, mime, output_format };
|
||||
|
||||
let ocrResult;
|
||||
try {
|
||||
ocrResult = await supplierHttp.postJson("/v1/ocr/extract", ocrBody, {
|
||||
headers: { "X-Escrow-Ref": escrowRefStr },
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
const reason = err.kind === "timeout" ? "timeout" : "network_error";
|
||||
const sErr = new SupplierError(reason, { message: err.message });
|
||||
recordFailure(reason);
|
||||
throw sErr;
|
||||
}
|
||||
recordFailure((err as Error).message);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 202 + poll mirror of submitTts's async handling. OCR inference for a
|
||||
// page runs seconds; the long tail is the Submit tx confirmation.
|
||||
if (ocrResult.status === 202 && ocrResult.body && typeof ocrResult.body === "object") {
|
||||
const jobId = (ocrResult.body as { job_id?: string }).job_id;
|
||||
if (typeof jobId !== "string" || jobId.length === 0) {
|
||||
const sErr = new SupplierError("malformed_response", {
|
||||
status: ocrResult.status,
|
||||
message: "supplier 202 response missing job_id",
|
||||
});
|
||||
recordFailure("malformed_response");
|
||||
throw sErr;
|
||||
}
|
||||
const POLL_INTERVAL_MS = 2_000;
|
||||
const POLL_TIMEOUT_MS = 180_000;
|
||||
const pollDeadline = Date.now() + POLL_TIMEOUT_MS;
|
||||
let polled = ocrResult;
|
||||
let firstIter = true;
|
||||
while (Date.now() < pollDeadline) {
|
||||
if (!firstIter) {
|
||||
await new Promise<void>((r) => setTimeout(r, POLL_INTERVAL_MS));
|
||||
}
|
||||
firstIter = false;
|
||||
try {
|
||||
polled = await supplierHttp.getJson(`/v1/ocr/extract/${jobId}`);
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
const reason = err.kind === "timeout" ? "timeout" : "network_error";
|
||||
const sErr = new SupplierError(reason, { message: err.message });
|
||||
recordFailure(reason);
|
||||
throw sErr;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (polled.status === 200) break;
|
||||
if (polled.status === 202) continue;
|
||||
const failBody = (polled.body && typeof polled.body === "object")
|
||||
? polled.body as { reason?: string; message?: string }
|
||||
: {};
|
||||
const sErr = new SupplierError(failBody.reason ?? "supplier_http_error", {
|
||||
status: polled.status,
|
||||
message: failBody.message ?? `supplier returned ${polled.status}`,
|
||||
});
|
||||
recordFailure(sErr.reason);
|
||||
throw sErr;
|
||||
}
|
||||
if (polled.status !== 200) {
|
||||
const sErr = new SupplierError("timeout", {
|
||||
status: polled.status,
|
||||
message: `supplier job ${jobId} not done within ${POLL_TIMEOUT_MS}ms`,
|
||||
});
|
||||
recordFailure("timeout");
|
||||
throw sErr;
|
||||
}
|
||||
ocrResult = polled;
|
||||
}
|
||||
|
||||
if (!ocrResult.ok) {
|
||||
const bodyReason =
|
||||
ocrResult.body && typeof ocrResult.body === "object"
|
||||
? ((ocrResult.body as { reason?: string }).reason ?? "supplier_http_error")
|
||||
: "supplier_http_error";
|
||||
const sErr = new SupplierError(bodyReason, {
|
||||
status: ocrResult.status,
|
||||
message: `supplier returned ${ocrResult.status}`,
|
||||
});
|
||||
recordFailure(bodyReason);
|
||||
throw sErr;
|
||||
}
|
||||
if (ocrResult.parseError || !ocrResult.body || typeof ocrResult.body !== "object") {
|
||||
const sErr = new SupplierError("malformed_response", {
|
||||
status: ocrResult.status,
|
||||
message: "supplier body is not valid JSON",
|
||||
});
|
||||
recordFailure("malformed_response");
|
||||
throw sErr;
|
||||
}
|
||||
|
||||
const responseBody = ocrResult.body as {
|
||||
output_format?: string;
|
||||
content?: string;
|
||||
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
|
||||
receipt?: import("@marketplace/shared/receipt").Receipt;
|
||||
receipt_signature?: string;
|
||||
};
|
||||
if (!responseBody.content || !responseBody.receipt || !responseBody.receipt_signature) {
|
||||
const sErr = new SupplierError("malformed_response", {
|
||||
status: ocrResult.status,
|
||||
message: "supplier response is missing content / receipt / receipt_signature",
|
||||
});
|
||||
recordFailure("malformed_response");
|
||||
throw sErr;
|
||||
}
|
||||
const receipt = responseBody.receipt;
|
||||
const receiptSignature = responseBody.receipt_signature;
|
||||
|
||||
this.emitProgress({ type: "supplier_called", escrow_ref: escrowRefStr });
|
||||
|
||||
// ── 5. Receipt verification ───────────────────────────────────────
|
||||
if (receipt.supplier_pkh !== advertDatum.supplier_pkh) {
|
||||
recordFailure("wrong_supplier");
|
||||
throw new ReceiptVerificationError("wrong_supplier");
|
||||
}
|
||||
if (receipt.escrow_ref !== escrowRefStr) {
|
||||
recordFailure("wrong_escrow_ref");
|
||||
throw new ReceiptVerificationError("wrong_escrow_ref");
|
||||
}
|
||||
// The OCR prompt commitment uses the SAME canonical hash the supplier
|
||||
// validated against the escrow datum. Recompute from the envelope we
|
||||
// sent; a mismatch means the supplier signed a different request.
|
||||
const expectedPromptHash = ocrPromptHash(request);
|
||||
if (receipt.prompt_hash !== expectedPromptHash) {
|
||||
recordFailure("prompt_hash_mismatch");
|
||||
throw new ReceiptVerificationError("prompt_hash_mismatch");
|
||||
}
|
||||
if (receipt.model !== advertDatum.model) {
|
||||
recordFailure("request_spec_hash_mismatch");
|
||||
throw new ReceiptVerificationError("request_spec_hash_mismatch");
|
||||
}
|
||||
// The receipt's response_hash must commit the content we actually
|
||||
// received in the requested output shape.
|
||||
const expectedResponseHash = nodeCrypto.createHash("sha256")
|
||||
.update(canonicalize({
|
||||
content: responseBody.content,
|
||||
output_format: responseBody.output_format ?? output_format,
|
||||
}), "utf8")
|
||||
.digest("hex");
|
||||
if (receipt.response_hash !== expectedResponseHash) {
|
||||
recordFailure("response_hash_mismatch");
|
||||
throw new ReceiptVerificationError("response_hash_mismatch");
|
||||
}
|
||||
if (typeof receiptSignature !== "string" || !SIG_RE.test(receiptSignature)) {
|
||||
recordFailure("invalid_signature");
|
||||
throw new ReceiptVerificationError("invalid_signature");
|
||||
}
|
||||
if (receiptSignature === ZERO_SIGNATURE) {
|
||||
recordFailure("invalid_signature");
|
||||
throw new ReceiptVerificationError("invalid_signature");
|
||||
}
|
||||
if (!HEX64_RE.test(receipt.prompt_hash) || !HEX64_RE.test(receipt.response_hash)) {
|
||||
recordFailure("malformed_receipt");
|
||||
throw new ReceiptVerificationError("malformed_receipt");
|
||||
}
|
||||
|
||||
this.emitProgress({ type: "receipt_verified", escrow_ref: escrowRefStr });
|
||||
|
||||
this.historyStore.save({
|
||||
escrow_ref: escrowRefStr,
|
||||
supplier_pkh: advertDatum.supplier_pkh,
|
||||
capability_id: advertDatum.capability_id,
|
||||
prompt_preview: previewStr,
|
||||
posted_at: postedAtMs,
|
||||
status: "completed",
|
||||
response: responseBody.content.length <= 200
|
||||
? responseBody.content
|
||||
: responseBody.content.slice(0, 200),
|
||||
receipt,
|
||||
receipt_signature: receiptSignature,
|
||||
});
|
||||
|
||||
return {
|
||||
output_format: responseBody.output_format ?? output_format,
|
||||
content: responseBody.content,
|
||||
usage: {
|
||||
prompt_tokens: responseBody.usage?.prompt_tokens ?? 0,
|
||||
completion_tokens: responseBody.usage?.completion_tokens ?? 0,
|
||||
total_tokens: responseBody.usage?.total_tokens ?? 0,
|
||||
},
|
||||
receipt,
|
||||
receiptSignature,
|
||||
escrowRef: escrowOutputRef,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── startChat / endChat — multi-turn llm.chat.v1 lifecycle ────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -187,6 +187,35 @@ export interface SubmitTtsResult {
|
||||
escrowRef: OutputReference;
|
||||
}
|
||||
|
||||
/**
|
||||
* SubmitOcrOptions — arguments to Marketplace.submitOcr() for model-scoped
|
||||
* `ocr.page.extract.<model-slug>.v1` capabilities. The same `{image_b64,
|
||||
* mime, output_format}` envelope is hashed (sha256 ∘ canonical) into the
|
||||
* escrow datum's `prompt_hash` AND posted to the supplier's /v1/ocr/extract
|
||||
* body, so the supplier can verify the request matches the on-chain commit.
|
||||
*/
|
||||
export interface SubmitOcrOptions {
|
||||
advertRef: OutputReference;
|
||||
image_b64: string; // plain base64, no data: prefix
|
||||
mime: string; // image/png | image/jpeg | image/webp
|
||||
output_format: string; // markdown | html | json
|
||||
payment_lovelace: bigint;
|
||||
}
|
||||
|
||||
/**
|
||||
* SubmitOcrResult — returned from Marketplace.submitOcr() on success.
|
||||
* `content` is the extraction in the requested output_format; the receipt's
|
||||
* response_hash commits sha256(canonical({content, output_format})).
|
||||
*/
|
||||
export interface SubmitOcrResult {
|
||||
output_format: string;
|
||||
content: string;
|
||||
usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
||||
receipt: Receipt;
|
||||
receiptSignature: string;
|
||||
escrowRef: OutputReference;
|
||||
}
|
||||
|
||||
/**
|
||||
* StartChatOptions / StartChatResult — Marketplace.startChat() for the
|
||||
* `llm.chat.v1` capability. Opens the escrow (session-init prompt_hash) and
|
||||
|
||||
@@ -49,6 +49,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet.rule=Host(`mp-suppliers-deepseek-flash-1.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet.middlewares=mp-suppliers-deepseek-flash-1-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-1-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-1-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet-tls.rule=Host(`mp-suppliers-deepseek-flash-1.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-deepseek-flash-1-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -49,6 +49,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet.rule=Host(`mp-suppliers-deepseek-flash-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet.middlewares=mp-suppliers-deepseek-flash-2-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-2-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-2-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet-tls.rule=Host(`mp-suppliers-deepseek-flash-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-deepseek-flash-2-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -49,6 +49,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet.rule=Host(`mp-suppliers-deepseek-flash-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet.middlewares=mp-suppliers-deepseek-flash-3-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-3-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-3-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet-tls.rule=Host(`mp-suppliers-deepseek-flash-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-deepseek-flash-3-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -65,6 +65,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-mainnet.rule=Host(`mp-suppliers-deepseek.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-mainnet.middlewares=mp-suppliers-deepseek-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-mainnet-tls.rule=Host(`mp-suppliers-deepseek.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-deepseek-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -58,6 +58,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet.rule=Host(`mp-suppliers-ds-flash-chat-1.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet.middlewares=mp-suppliers-ds-flash-chat-1-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-1-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-1-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet-tls.rule=Host(`mp-suppliers-ds-flash-chat-1.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-ds-flash-chat-1-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -58,6 +58,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet.rule=Host(`mp-suppliers-ds-flash-chat-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet.middlewares=mp-suppliers-ds-flash-chat-2-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-2-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-2-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet-tls.rule=Host(`mp-suppliers-ds-flash-chat-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-ds-flash-chat-2-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -58,6 +58,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet.rule=Host(`mp-suppliers-ds-flash-chat-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet.middlewares=mp-suppliers-ds-flash-chat-3-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-3-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-3-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet-tls.rule=Host(`mp-suppliers-ds-flash-chat-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-ds-flash-chat-3-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -61,6 +61,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-gpt-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-gpt-mainnet.rule=Host(`mp-suppliers-gpt.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-gpt-mainnet.middlewares=mp-suppliers-gpt-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-gpt-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-gpt-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-gpt-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-gpt-mainnet-tls.rule=Host(`mp-suppliers-gpt.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-gpt-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-gpt-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -51,6 +51,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet.rule=Host(`mp-suppliers-kimi-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet.middlewares=mp-suppliers-kimi-2-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-2-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-2-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet-tls.rule=Host(`mp-suppliers-kimi-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-2-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -51,6 +51,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet.rule=Host(`mp-suppliers-kimi-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet.middlewares=mp-suppliers-kimi-3-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-3-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-3-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet-tls.rule=Host(`mp-suppliers-kimi-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-3-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -51,6 +51,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet.rule=Host(`mp-suppliers-kimi-4.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet.middlewares=mp-suppliers-kimi-4-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-4-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-4-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet-tls.rule=Host(`mp-suppliers-kimi-4.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-4-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -62,6 +62,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet.rule=Host(`mp-suppliers-kimi-chat-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet.middlewares=mp-suppliers-kimi-chat-2-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-chat-2-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-chat-2-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet-tls.rule=Host(`mp-suppliers-kimi-chat-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-chat-2-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -62,6 +62,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet.rule=Host(`mp-suppliers-kimi-chat-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet.middlewares=mp-suppliers-kimi-chat-3-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-chat-3-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-chat-3-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet-tls.rule=Host(`mp-suppliers-kimi-chat-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-chat-3-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -74,6 +74,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet.rule=Host(`mp-suppliers-kimi-chat.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet.middlewares=mp-suppliers-kimi-chat-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-chat-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-chat-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet-tls.rule=Host(`mp-suppliers-kimi-chat.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-chat-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -75,6 +75,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-mainnet.rule=Host(`mp-suppliers-kimi.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-mainnet.middlewares=mp-suppliers-kimi-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-mainnet-tls.rule=Host(`mp-suppliers-kimi.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -65,6 +65,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-tts-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-tts-mainnet.rule=Host(`mp-suppliers-tts.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-tts-mainnet.middlewares=mp-suppliers-tts-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-tts-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-tts-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-tts-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-tts-mainnet-tls.rule=Host(`mp-suppliers-tts.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-tts-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-tts-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -60,6 +60,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-mainnet.rule=Host(`mp-suppliers.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-mainnet.middlewares=mp-suppliers-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-mainnet-tls.rule=Host(`mp-suppliers.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -22,6 +22,8 @@ RUN apk add --no-cache python3 make g++
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
|
||||
# pnpm-lock.yaml's patchedDependencies hash the patch files at install time.
|
||||
COPY patches ./patches
|
||||
COPY packages/shared/package.json ./packages/shared/package.json
|
||||
COPY buyer/package.json ./buyer/package.json
|
||||
COPY gateway/package.json ./gateway/package.json
|
||||
|
||||
@@ -53,6 +53,9 @@ export interface GatewayConfig {
|
||||
walletHealthIntervalMs: number;
|
||||
sdkRegistryMax: number;
|
||||
corsOrigins: string[];
|
||||
/** Capability id the /v1/ocr/extract route escrows against. Model-scoped
|
||||
* per the marketplace convention (one model, one id). Env: OCR_CAPABILITY_ID. */
|
||||
ocrCapabilityId: string;
|
||||
}
|
||||
|
||||
function requireField(env: Record<string, string | undefined>, name: string): string {
|
||||
@@ -138,6 +141,7 @@ export function loadConfig(env: Record<string, string | undefined>): GatewayConf
|
||||
chatSettleMode: chatSettleMode(env),
|
||||
walletHealthIntervalMs: posInt(env, "WALLET_HEALTH_INTERVAL_MS", 10 * 60 * 1000),
|
||||
sdkRegistryMax: posInt(env, "SDK_REGISTRY_MAX", 500),
|
||||
ocrCapabilityId: env.OCR_CAPABILITY_ID ?? "ocr.page.extract.chandra-ocr-2.v1",
|
||||
corsOrigins: (env.GATEWAY_CORS_ORIGINS ?? "")
|
||||
.split(",")
|
||||
.map((o) => o.trim().replace(/\/+$/, ""))
|
||||
|
||||
@@ -57,7 +57,10 @@ export function runMain(env: Record<string, string | undefined>): void {
|
||||
const invokedDirectly =
|
||||
typeof process !== "undefined" &&
|
||||
process.argv?.[1] !== undefined &&
|
||||
/gateway\/(src|dist)\/index\.(t|j)s$/.test(process.argv[1]);
|
||||
// Accept both path separators: on Windows process.argv[1] arrives with
|
||||
// backslashes, so a forward-slash-only pattern silently never matches and
|
||||
// the process exits 0 without ever starting the server.
|
||||
/gateway[\\/](src|dist)[\\/]index\.(t|j)s$/.test(process.argv[1]);
|
||||
|
||||
if (invokedDirectly) {
|
||||
try {
|
||||
|
||||
402
gateway/src/ocr/extract.ts
Normal file
402
gateway/src/ocr/extract.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* gateway/src/ocr/extract.ts — POST /v1/ocr/extract.
|
||||
*
|
||||
* One escrow per page against a model-scoped `ocr.page.extract.<model>.v1`
|
||||
* supplier (config.ocrCapabilityId). Lifecycle mirrors the one-shot chat
|
||||
* path, all inside the per-key mutex: parse → select supplier → preflight →
|
||||
* per-candidate live busy pre-check → submitOcr (escrow → supplier call →
|
||||
* receipt verify) → resolve Submitted ref → Accept (awaited) → record usage
|
||||
* → wallet-health → respond.
|
||||
*
|
||||
* Response (200):
|
||||
* {
|
||||
* output_format, content,
|
||||
* usage: { prompt_tokens, completion_tokens, total_tokens },
|
||||
* x_vector: {
|
||||
* receipt, receipt_signature, escrow_ref,
|
||||
* receipt_labels: ["first-party-brokered"]
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Venue attribution: the caller MAY send
|
||||
* X-Venue: venue slug (e.g. "apify"), ≤ 32 chars
|
||||
* X-Run-User-Hash: sha256 of the venue-side runner id, 64 hex
|
||||
* Both are logged on a structured line per settled job. The wedge-ledger
|
||||
* emitter consumes these lines; a usage-table venue
|
||||
* column is deliberately deferred to later work so this route stays
|
||||
* schema-additive. payer_class is derived at ledger time from the hash
|
||||
* (known first-party hashes vs the rest), never asserted here.
|
||||
*
|
||||
* Demo keys are rejected: the demo executor is chat-session-shaped and its
|
||||
* wallet holds one escrow per session; OCR needs a funded per-key wallet.
|
||||
*/
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
import type { Request, Response } from "express";
|
||||
import { TxConstructionError } from "@marketplace/shared/tx";
|
||||
import {
|
||||
ALLOWED_OCR_MIMES,
|
||||
ALLOWED_OCR_OUTPUT_FORMATS,
|
||||
MAX_OCR_IMAGE_B64_CHARS,
|
||||
} from "@marketplace/shared/tx";
|
||||
import { SupplierError, type ProgressEvent } from "@marketplace/buyer/sdk";
|
||||
import type { GatewayDeps } from "../deps.js";
|
||||
import type { GatewayStore } from "../db/store.js";
|
||||
import { requireKey } from "../middleware/apiKeyAuth.js";
|
||||
import { asyncHandler } from "../middleware/http.js";
|
||||
import { selectCandidates, type SupplierCandidate } from "../routing/selectSupplier.js";
|
||||
import { preflight } from "../onchain/preflight.js";
|
||||
import { resolveSubmittedRef, acceptAndConfirm } from "../onchain/settle.js";
|
||||
import { ensureWalletHealthy } from "../walletHealth.js";
|
||||
import { badRequest, forbidden, notFound, paymentRequired, toGatewayError } from "../openai/errors.js";
|
||||
|
||||
/** Operator-model v0: HAL8 operates both the custodial buyer and the
|
||||
* supplier fleet, so every receipt this route returns is first-party
|
||||
* brokered. Becomes per-supplier metadata when
|
||||
* control-independent supply exists. */
|
||||
const RECEIPT_LABELS_V0 = ["first-party-brokered"] as const;
|
||||
|
||||
const B64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
||||
const VENUE_RE = /^[a-z0-9-]{1,32}$/;
|
||||
|
||||
/** Bound on the live busy pre-check below - cheap enough that a stalled
|
||||
* supplier never meaningfully delays the request. */
|
||||
const SUPPLIER_STATUS_CHECK_TIMEOUT_MS = 2_000;
|
||||
|
||||
interface ParsedOcrRequest {
|
||||
image_b64: string;
|
||||
mime: string;
|
||||
output_format: string;
|
||||
/** "" = any model under the capability (single-model ids). */
|
||||
model: string;
|
||||
}
|
||||
|
||||
function parseOcrRequest(body: unknown): ParsedOcrRequest {
|
||||
if (typeof body !== "object" || body === null) {
|
||||
throw badRequest("invalid_body", "request body must be a JSON object");
|
||||
}
|
||||
const b = body as Record<string, unknown>;
|
||||
|
||||
const image = b.image_b64;
|
||||
if (typeof image !== "string" || image.length === 0) {
|
||||
throw badRequest("invalid_image", "`image_b64` is required and must be a non-empty base64 string");
|
||||
}
|
||||
if (image.length > MAX_OCR_IMAGE_B64_CHARS) {
|
||||
throw badRequest("image_too_large", `\`image_b64\` exceeds ${MAX_OCR_IMAGE_B64_CHARS} characters`);
|
||||
}
|
||||
if (!B64_RE.test(image)) {
|
||||
throw badRequest("invalid_image", "`image_b64` must be plain base64 (no data: prefix, no whitespace)");
|
||||
}
|
||||
|
||||
const mime = b.mime;
|
||||
if (typeof mime !== "string" || !ALLOWED_OCR_MIMES.has(mime)) {
|
||||
throw badRequest("invalid_mime", `\`mime\` must be one of: ${[...ALLOWED_OCR_MIMES].join(", ")}`);
|
||||
}
|
||||
|
||||
const rawFormat = b.output_format ?? "markdown";
|
||||
if (typeof rawFormat !== "string" || !ALLOWED_OCR_OUTPUT_FORMATS.has(rawFormat)) {
|
||||
throw badRequest(
|
||||
"invalid_output_format",
|
||||
`\`output_format\` must be one of: ${[...ALLOWED_OCR_OUTPUT_FORMATS].join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
let model = "";
|
||||
if (b.model !== undefined && b.model !== null) {
|
||||
if (typeof b.model !== "string" || b.model === "") {
|
||||
throw badRequest("invalid_model", "`model` must be a non-empty string when provided");
|
||||
}
|
||||
model = b.model;
|
||||
}
|
||||
|
||||
return { image_b64: image, mime, output_format: rawFormat, model };
|
||||
}
|
||||
|
||||
/** Sanitized venue-attribution headers. Invalid values drop to "".
|
||||
* Exported for direct unit testing (I3 review finding) — it is the only
|
||||
* gate on what lands in the ocr_settled/ocr_failed structured log lines the
|
||||
* wedge-ledger emitter reads, so it needs coverage independent of the HTTP
|
||||
* route. */
|
||||
export function venueAttribution(req: Request): { venue: string; runUserHash: string } {
|
||||
const venueRaw = req.header("X-Venue") ?? "";
|
||||
const hashRaw = req.header("X-Run-User-Hash") ?? "";
|
||||
return {
|
||||
venue: VENUE_RE.test(venueRaw) ? venueRaw : "",
|
||||
runUserHash: HEX64_RE.test(hashRaw) ? hashRaw.toLowerCase() : "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Live busy pre-check, run immediately before a candidate's escrow posts.
|
||||
* Mirrors sessions.ts's startChat pre-check: same signal ("working" on the
|
||||
* supplier's own /status), same best-effort posture. Posting an escrow only
|
||||
* to have the supplier reject as busy strands the escrow until reclaim and
|
||||
* burns the tx fee, so this is worth a bounded, cheap HTTP round trip.
|
||||
*
|
||||
* Never a gate: the indexer already filters candidates on advert status, so
|
||||
* a timeout, network error, non-2xx, or malformed body all resolve to "not
|
||||
* busy" (unknown, not blocking) - a briefly-unreachable /status must never
|
||||
* stop the candidate from being attempted.
|
||||
*/
|
||||
async function isSupplierBusy(
|
||||
endpointUrl: string,
|
||||
fetchFn: typeof globalThis.fetch,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetchFn(`${endpointUrl.replace(/\/+$/, "")}/status`, {
|
||||
signal: AbortSignal.timeout(SUPPLIER_STATUS_CHECK_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const body = (await res.json()) as unknown;
|
||||
const status = body && typeof body === "object" ? (body as { status?: unknown }).status : undefined;
|
||||
return status === "working";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function recordOcrUsage(
|
||||
store: GatewayStore,
|
||||
p: {
|
||||
keyId: string;
|
||||
capabilityId: string;
|
||||
model: string;
|
||||
supplierPkh: string | null;
|
||||
escrowRef: string | null;
|
||||
costLovelace: bigint | null;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
status: "completed" | "failed";
|
||||
failureReason?: string;
|
||||
},
|
||||
): void {
|
||||
store.insertUsage({
|
||||
id: randomUUID(),
|
||||
key_id: p.keyId,
|
||||
created_at: Date.now(),
|
||||
kind: "ocr",
|
||||
model: p.model,
|
||||
capability_id: p.capabilityId,
|
||||
supplier_pkh: p.supplierPkh,
|
||||
escrow_ref: p.escrowRef,
|
||||
cost_lovelace: p.costLovelace !== null ? p.costLovelace.toString() : null,
|
||||
prompt_tokens: p.promptTokens,
|
||||
completion_tokens: p.completionTokens,
|
||||
status: p.status,
|
||||
failure_reason: p.failureReason ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export function makeOcrExtractHandler(deps: GatewayDeps) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const keyRow = requireKey(req);
|
||||
if (keyRow.demo) {
|
||||
throw forbidden("demo_key_unsupported", "demo keys cannot use /v1/ocr/extract; create a funded API key");
|
||||
}
|
||||
const ctx = deps.registry.getContext(keyRow);
|
||||
const parsed = parseOcrRequest(req.body);
|
||||
const attribution = venueAttribution(req);
|
||||
await ctx.mutex.run(() => runOcrOneShot(deps, keyRow.id, ctx, parsed, attribution, res));
|
||||
});
|
||||
}
|
||||
|
||||
async function runOcrOneShot(
|
||||
deps: GatewayDeps,
|
||||
keyId: string,
|
||||
ctx: ReturnType<GatewayDeps["registry"]["getContext"]>,
|
||||
parsed: ParsedOcrRequest,
|
||||
attribution: { venue: string; runUserHash: string },
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const { config, store, chain, fetchFn } = deps;
|
||||
const capabilityId = config.ocrCapabilityId;
|
||||
|
||||
// Hoisted above the try so the catch block can report whatever is known
|
||||
// even when the failure happens AFTER an escrow was posted (e.g. the
|
||||
// supplier call or settle step fails) — these fields are the
|
||||
// reconciliation spine that joins on-chain settlement to platform
|
||||
// billing, and losing them makes paid-but-failed jobs unreconcilable
|
||||
// (I2 review finding).
|
||||
let result: Awaited<ReturnType<typeof ctx.sdk.submitOcr>> | undefined;
|
||||
let used: SupplierCandidate | undefined;
|
||||
let escrowRefStr: string | null = null;
|
||||
|
||||
try {
|
||||
const candidates = await selectCandidates({
|
||||
indexerUrl: config.indexerUrl,
|
||||
model: parsed.model,
|
||||
capabilityId,
|
||||
fetchFn,
|
||||
});
|
||||
if (candidates.length === 0) {
|
||||
throw notFound(
|
||||
"model_not_found",
|
||||
parsed.model === ""
|
||||
? `no available supplier for capability "${capabilityId}"`
|
||||
: `no available supplier for model "${parsed.model}" under "${capabilityId}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const primary = candidates[0];
|
||||
const pf = await preflight(chain, ctx.walletKey.address, primary);
|
||||
if (!pf.ok) {
|
||||
throw paymentRequired(
|
||||
pf.collateralOk
|
||||
? "insufficient balance for this request"
|
||||
: "wallet has no pure-AP3X UTxO ≥ 5 AP3X for collateral; deposit a little more AP3X",
|
||||
{
|
||||
x_vector: {
|
||||
required_lovelace: pf.requiredLovelace.toString(),
|
||||
available_lovelace: pf.availableLovelace.toString(),
|
||||
collateral_ok: pf.collateralOk,
|
||||
deposit_address: ctx.walletKey.address,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Submit. Fall back to the next supplier ONLY on a pre-post
|
||||
// TxConstructionError (no escrow was posted). A SupplierError means the
|
||||
// escrow is already posted — do not retry (the sweeper recovers it).
|
||||
//
|
||||
// submitOcr posts the escrow internally and only returns escrowRef when
|
||||
// it resolves. When the supplier call AFTER the post throws
|
||||
// (SupplierError: timeout, network failure, non-2xx, malformed
|
||||
// response, receipt mismatch) the escrow already landed on chain but
|
||||
// escrowRefStr below would otherwise stay null — the OCR upstream has a
|
||||
// 120s budget, so this is likely the most common paid-but-failed shape.
|
||||
// The SDK emits a "progress" event of type "escrow_posted" (carrying
|
||||
// escrow_ref) the instant the tx confirms, so listen for it around each
|
||||
// attempt and capture the ref from the event rather than only from
|
||||
// submitOcr's return value (I2 follow-up).
|
||||
let lastErr: unknown;
|
||||
for (const cand of candidates) {
|
||||
// Live pre-check, run right here (not once at the top of the request):
|
||||
// skip a candidate that is busy right now instead of paying to find
|
||||
// out. See isSupplierBusy's docstring for the exact signal + posture.
|
||||
if (await isSupplierBusy(cand.endpointUrl, fetchFn)) {
|
||||
lastErr = new SupplierError("supplier_busy", {
|
||||
status: 409,
|
||||
message: "supplier is busy with another job",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const onProgress = (ev: unknown): void => {
|
||||
const e = ev as ProgressEvent;
|
||||
if (e.type === "escrow_posted" && e.escrow_ref) escrowRefStr = e.escrow_ref;
|
||||
};
|
||||
ctx.sdk.on("progress", onProgress);
|
||||
try {
|
||||
result = await ctx.sdk.submitOcr({
|
||||
advertRef: cand.advertRef,
|
||||
image_b64: parsed.image_b64,
|
||||
mime: parsed.mime,
|
||||
output_format: parsed.output_format,
|
||||
payment_lovelace: cand.priceLovelace,
|
||||
});
|
||||
used = cand;
|
||||
break;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (err instanceof TxConstructionError) continue;
|
||||
throw err;
|
||||
} finally {
|
||||
ctx.sdk.off("progress", onProgress);
|
||||
}
|
||||
}
|
||||
if (!result || !used) {
|
||||
throw toGatewayError(lastErr ?? new Error("no supplier could be engaged"));
|
||||
}
|
||||
|
||||
// Settle: resolve the live Submitted ref, Accept, await confirmation.
|
||||
escrowRefStr = `${result.escrowRef.txHash}#${result.escrowRef.index}`;
|
||||
const submittedRef = await resolveSubmittedRef({
|
||||
indexerUrl: config.indexerUrl,
|
||||
buyerPkh: ctx.walletKey.pubKeyHash,
|
||||
originalRefStr: escrowRefStr,
|
||||
fetchFn,
|
||||
});
|
||||
await acceptAndConfirm(chain, ctx.walletKey, submittedRef);
|
||||
|
||||
recordOcrUsage(store, {
|
||||
keyId,
|
||||
capabilityId,
|
||||
model: used.model,
|
||||
supplierPkh: used.supplierPkh,
|
||||
escrowRef: escrowRefStr,
|
||||
costLovelace: used.priceLovelace,
|
||||
promptTokens: result.usage.prompt_tokens,
|
||||
completionTokens: result.usage.completion_tokens,
|
||||
status: "completed",
|
||||
});
|
||||
|
||||
// Structured settle line for the wedge-ledger emitter. One line per
|
||||
// settled job; venue/run_user_hash empty when the
|
||||
// caller sent none.
|
||||
console.log(JSON.stringify({
|
||||
evt: "ocr_settled",
|
||||
escrow_ref: escrowRefStr,
|
||||
capability_id: capabilityId,
|
||||
model: used.model,
|
||||
supplier_pkh: used.supplierPkh,
|
||||
cost_lovelace: used.priceLovelace.toString(),
|
||||
receipt_labels: RECEIPT_LABELS_V0,
|
||||
venue: attribution.venue,
|
||||
run_user_hash: attribution.runUserHash,
|
||||
ts: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
// Re-shape the wallet ({collateral, working}) so the next request keeps
|
||||
// a valid collateral UTxO. Best-effort; serialized by the mutex.
|
||||
await ensureWalletHealthy(chain, ctx.walletKey).catch((e) =>
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[gateway] wallet-health after ocr:", e instanceof Error ? e.message : e),
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
output_format: result.output_format,
|
||||
content: result.content,
|
||||
usage: result.usage,
|
||||
x_vector: {
|
||||
receipt: result.receipt,
|
||||
receipt_signature: result.receiptSignature,
|
||||
escrow_ref: escrowRefStr,
|
||||
receipt_labels: RECEIPT_LABELS_V0,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
recordOcrUsage(store, {
|
||||
keyId,
|
||||
capabilityId,
|
||||
model: used?.model ?? parsed.model,
|
||||
supplierPkh: used?.supplierPkh ?? null,
|
||||
escrowRef: escrowRefStr,
|
||||
costLovelace: used?.priceLovelace ?? null,
|
||||
promptTokens: result?.usage.prompt_tokens ?? 0,
|
||||
completionTokens: result?.usage.completion_tokens ?? 0,
|
||||
status: "failed",
|
||||
failureReason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
|
||||
// Structured failure line mirroring ocr_settled's shape (same venue /
|
||||
// run_user_hash / escrow_ref fields) so paid-but-failed jobs — an
|
||||
// escrow posted on-chain, then a later step (supplier call, settle)
|
||||
// failed — can still be reconciled against on-chain state instead of
|
||||
// vanishing from the wedge-ledger emitter's input (I2 review finding).
|
||||
console.log(JSON.stringify({
|
||||
evt: "ocr_failed",
|
||||
escrow_ref: escrowRefStr,
|
||||
capability_id: capabilityId,
|
||||
model: used?.model ?? parsed.model,
|
||||
supplier_pkh: used?.supplierPkh ?? null,
|
||||
venue: attribution.venue,
|
||||
run_user_hash: attribution.runUserHash,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
ts: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,20 @@
|
||||
* gateway/src/onchain/preflight.ts — balance + collateral pre-flight.
|
||||
*
|
||||
* The buyer wallet funds the FULL escrow at PostEscrow: price + buyer_bond +
|
||||
* supplier_bond (postEscrow.ts:132). Accept/Reclaim are Plutus script-spends
|
||||
* that also need a pure-AP3X collateral UTxO ≥ 5 AP3X (assertCollateralCandidate).
|
||||
* So required = price + buyer_bond + supplier_bond + collateral + fee reserve,
|
||||
* AND the wallet must currently hold a pure-AP3X UTxO ≥ collateral. We check both
|
||||
* up front and return a clean 402 instead of failing mid-flight.
|
||||
* supplier_bond, raised to the Submitted-state min-ada floor so long
|
||||
* capability ids (e.g. the 33-byte OCR one) don't get bumped mid-flight —
|
||||
* see escrowLockFloor (packages/shared/.../minAdaFloor.ts) and
|
||||
* postOcrEscrow.ts:205, the SAME floor a POST actually locks. Accept/Reclaim
|
||||
* are Plutus script-spends that also need a pure-AP3X collateral UTxO ≥ 5
|
||||
* AP3X (assertCollateralCandidate). So required = escrowLockFloor(price +
|
||||
* buyer_bond + supplier_bond) + collateral + fee reserve, AND the wallet
|
||||
* must currently hold a pure-AP3X UTxO ≥ collateral. We check both up front
|
||||
* and return a clean 402 instead of failing mid-flight.
|
||||
*/
|
||||
|
||||
import type { ChainProvider, Utxo } from "@marketplace/shared/chain";
|
||||
import type { EscrowDatum } from "@marketplace/shared/cbor";
|
||||
import { escrowLockFloor } from "@marketplace/shared/tx";
|
||||
|
||||
export const COLLATERAL_MIN_LOVELACE = 5_000_000n;
|
||||
export const FEE_RESERVE_LOVELACE = 2_000_000n;
|
||||
@@ -32,20 +38,48 @@ export interface PreflightResult {
|
||||
collateralOk: boolean;
|
||||
}
|
||||
|
||||
export interface PreflightCost {
|
||||
capabilityId: string;
|
||||
priceLovelace: bigint;
|
||||
buyerBondLovelace: bigint;
|
||||
supplierBondLovelace: bigint;
|
||||
}
|
||||
|
||||
/** Placeholder datum used only to size-estimate the Submitted-state min-ada
|
||||
* floor. Every field except capability_id and the cost amounts is a
|
||||
* fixed-size stand-in (real hash/pkh byte widths, a realistic 13-digit
|
||||
* POSIX-ms timestamp) — capability_id's actual length is what drives the
|
||||
* estimate, exactly as in escrowLockFloor's own contract. */
|
||||
function representativeEscrowDatum(cost: PreflightCost): EscrowDatum {
|
||||
return {
|
||||
buyer_pkh: "3b2fdaed0398485ff35887fb7d0ad2025348864407854f7831dc6a55",
|
||||
supplier_pkh: "401af418a7e6fb106277292881fcdeaa1cc15b5b112e9c8bdb2d46c9",
|
||||
advert_ref: { txHash: "a6".repeat(32), index: 0 },
|
||||
capability_id: cost.capabilityId,
|
||||
request_spec_hash: "1b".repeat(32),
|
||||
prompt_hash: "23".repeat(32),
|
||||
payment_lovelace: cost.priceLovelace,
|
||||
buyer_bond_lovelace: cost.buyerBondLovelace,
|
||||
supplier_bond_lovelace: cost.supplierBondLovelace,
|
||||
deliver_by: 1_786_105_955_257,
|
||||
posted_at: 1_786_105_625_257,
|
||||
submitted_at: null,
|
||||
result_receipt_hash: null,
|
||||
state: "Open",
|
||||
};
|
||||
}
|
||||
|
||||
export async function preflight(
|
||||
chain: ChainProvider,
|
||||
address: string,
|
||||
cost: { priceLovelace: bigint; buyerBondLovelace: bigint; supplierBondLovelace: bigint },
|
||||
cost: PreflightCost,
|
||||
): Promise<PreflightResult> {
|
||||
const utxos = await chain.queryUtxosByAddress(address);
|
||||
const available = totalLovelace(utxos);
|
||||
const collateralOk = hasCollateral(utxos);
|
||||
const required =
|
||||
cost.priceLovelace +
|
||||
cost.buyerBondLovelace +
|
||||
cost.supplierBondLovelace +
|
||||
COLLATERAL_MIN_LOVELACE +
|
||||
FEE_RESERVE_LOVELACE;
|
||||
const economicTotal = cost.priceLovelace + cost.buyerBondLovelace + cost.supplierBondLovelace;
|
||||
const escrowFloor = escrowLockFloor(representativeEscrowDatum(cost), economicTotal);
|
||||
const required = escrowFloor + COLLATERAL_MIN_LOVELACE + FEE_RESERVE_LOVELACE;
|
||||
return {
|
||||
ok: available >= required && collateralOk,
|
||||
availableLovelace: available,
|
||||
|
||||
@@ -53,6 +53,9 @@ export function parseRef(ref: string): OutputReference | null {
|
||||
|
||||
export interface SelectSupplierOpts {
|
||||
indexerUrl: string;
|
||||
/** Exact model match; "" matches ANY model under the capability (used by
|
||||
* single-model capabilities like the ocr.* ids, where the capability
|
||||
* already pins the model). */
|
||||
model: string;
|
||||
capabilityId: string;
|
||||
fetchFn?: typeof globalThis.fetch;
|
||||
@@ -82,7 +85,7 @@ export async function selectCandidates(opts: SelectSupplierOpts): Promise<Suppli
|
||||
const candidates: SupplierCandidate[] = [];
|
||||
for (const raw of body as SupplierView[]) {
|
||||
if (raw.capability_id !== opts.capabilityId) continue;
|
||||
if (raw.model !== opts.model) continue;
|
||||
if (opts.model !== "" && raw.model !== opts.model) continue;
|
||||
if (raw.advert_status !== "Active") continue;
|
||||
if (raw.status !== "free" && raw.status !== "unknown" && !opts.ignoreStatusFor?.has(raw.supplier_pkh)) {
|
||||
continue;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ipRateLimit, keyRateLimit, demoIpRateLimit } from "./middleware/rateLim
|
||||
import { sendError } from "./middleware/http.js";
|
||||
import { notFound } from "./openai/errors.js";
|
||||
import { makeChatCompletionsHandler } from "./openai/chatCompletions.js";
|
||||
import { makeOcrExtractHandler } from "./ocr/extract.js";
|
||||
import { makeModelsHandler } from "./openai/models.js";
|
||||
import {
|
||||
makeOpenSessionHandler,
|
||||
@@ -56,7 +57,11 @@ export function createApp(deps: GatewayDeps): Express {
|
||||
app.set("trust proxy", true);
|
||||
// CORS before body-parsing so preflight OPTIONS short-circuits cheaply.
|
||||
app.use(corsMiddleware(deps.config.corsOrigins));
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
// /v1/ocr carries a base64 page image and mounts its own larger parser on
|
||||
// the route; every other path keeps the 1mb ceiling.
|
||||
const jsonBody = express.json({ limit: "1mb" });
|
||||
app.use((req, res, next) =>
|
||||
req.path.startsWith("/v1/ocr/") ? next() : jsonBody(req, res, next));
|
||||
|
||||
app.get("/healthz", (_req: Request, res: Response) => {
|
||||
res.json({ ok: true });
|
||||
@@ -80,6 +85,12 @@ export function createApp(deps: GatewayDeps): Express {
|
||||
app.post("/account/withdraw", auth, makeWithdrawHandler(deps));
|
||||
|
||||
app.post("/openai/v1/chat/completions", ...gated, makeChatCompletionsHandler(deps));
|
||||
// Model-scoped OCR: 16mb parser covers MAX_OCR_IMAGE_B64_CHARS
|
||||
// (~12M chars ≈ 9 MB binary) plus JSON envelope. Gates (auth + rate
|
||||
// limits) run BEFORE the parser so an unauthenticated caller cannot
|
||||
// buffer/JSON.parse up to 16mb before getting a 401, and rate limiting
|
||||
// can actually throttle the request (2026-08 review finding C2).
|
||||
app.post("/v1/ocr/extract", ...gated, express.json({ limit: "16mb" }), makeOcrExtractHandler(deps));
|
||||
app.get("/openai/v1/models", auth, makeModelsHandler(deps));
|
||||
app.post("/openai/v1/chat/sessions", ...gated, makeOpenSessionHandler(deps));
|
||||
app.post("/openai/v1/chat/sessions/:id/messages", ...gated, makeSessionMessageHandler(deps));
|
||||
|
||||
@@ -46,6 +46,7 @@ function makeDeps(
|
||||
chatSettleMode: overrides?.chatSettleMode ?? "full",
|
||||
walletHealthIntervalMs: 600_000,
|
||||
sdkRegistryMax: 100,
|
||||
ocrCapabilityId: "ocr.page.extract.chandra-ocr-2.v1",
|
||||
corsOrigins: [],
|
||||
};
|
||||
const chain = overrides?.chain ?? ({ queryUtxosByAddress: async () => [] } as unknown as GatewayDeps["chain"]);
|
||||
|
||||
572
gateway/test/ocr.test.ts
Normal file
572
gateway/test/ocr.test.ts
Normal file
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* gateway/test/ocr.test.ts — POST /v1/ocr/extract route coverage.
|
||||
*
|
||||
* Focus: auth gating, demo-key rejection, body validation, capability-based
|
||||
* supplier lookup, and the enlarged body limit. The settle path (submitOcr →
|
||||
* resolveSubmittedRef → acceptAndConfirm) reuses the one-shot chat plumbing
|
||||
* covered by http.test.ts + the SDK suites; end-to-end runs live on testnet.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { randomBytes, randomUUID } from "crypto";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../src/server.js";
|
||||
import { GatewayStore } from "../src/db/store.js";
|
||||
import { SdkRegistry } from "../src/sdk/registry.js";
|
||||
import { genPrivKeyHex, deriveWalletKey } from "../src/wallet.js";
|
||||
import { seal } from "../src/crypto/seal.js";
|
||||
import { hashApiKey } from "../src/middleware/apiKeyAuth.js";
|
||||
import { venueAttribution } from "../src/ocr/extract.js";
|
||||
import type { GatewayConfig } from "../src/config.js";
|
||||
import type { GatewayDeps } from "../src/deps.js";
|
||||
import { SupplierError } from "@marketplace/buyer/sdk";
|
||||
|
||||
// The one-shot settle step (resolveSubmittedRef → acceptAndConfirm) talks to
|
||||
// the live indexer + chain; the happy-path route test below only needs to
|
||||
// prove that a successful submitOcr reaches the response with the
|
||||
// first-party-brokered disclosure label (I3), so it stubs just those two
|
||||
// chain-touching steps and keeps every other export real.
|
||||
vi.mock("../src/onchain/settle.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../src/onchain/settle.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveSubmittedRef: vi.fn(async () => ({ txHash: "e".repeat(64), index: 0 })),
|
||||
acceptAndConfirm: vi.fn(async () => "f".repeat(64)),
|
||||
};
|
||||
});
|
||||
|
||||
const MASTER = "ab".repeat(32);
|
||||
const IMG = Buffer.from("page bytes").toString("base64");
|
||||
|
||||
/** A funded wallet: covers price+bonds+collateral+fee and has pure-ADA ≥ 5. */
|
||||
const fundedChain = {
|
||||
queryUtxosByAddress: async () => [
|
||||
{ ref: { txHash: "ff".repeat(32), index: 0 }, address: "a", lovelace: 20_000_000n, assets: {}, datumHex: null, scriptRef: null },
|
||||
],
|
||||
} as unknown as GatewayDeps["chain"];
|
||||
|
||||
const OCR_SUPPLIER = {
|
||||
utxo_ref: `${"cc".repeat(32)}#0`,
|
||||
supplier_pkh: "s3",
|
||||
capability_id: "ocr.page.extract.chandra-ocr-2.v1",
|
||||
model: "chandra-ocr-2",
|
||||
max_output_tokens: 4096,
|
||||
price_lovelace: "200000",
|
||||
supplier_bond_lovelace: "1000000",
|
||||
buyer_bond_lovelace: "1000000",
|
||||
endpoint_url: "http://sup",
|
||||
advert_status: "Active",
|
||||
status: "free",
|
||||
};
|
||||
|
||||
function makeDeps(fetchFn?: typeof globalThis.fetch, overrides?: { chain?: GatewayDeps["chain"] }): GatewayDeps {
|
||||
const dbDir = join(tmpdir(), `gw-ocr-test-${randomUUID()}`);
|
||||
const store = new GatewayStore(dbDir);
|
||||
const config: GatewayConfig = {
|
||||
masterKeyHex: MASTER,
|
||||
indexerUrl: "http://ix",
|
||||
ogmiosUrl: "http://og",
|
||||
networkId: 0,
|
||||
liveChain: true,
|
||||
port: 0,
|
||||
dbDir,
|
||||
signupRate: { max: 1000, windowMs: 60_000 },
|
||||
keyRate: { max: 1000, windowMs: 60_000 },
|
||||
demoIpRate: { max: 1000, windowMs: 60_000 },
|
||||
sweeperIntervalMs: 60_000,
|
||||
demoSessionIdleMs: 180_000,
|
||||
chatSettleMode: "full",
|
||||
walletHealthIntervalMs: 600_000,
|
||||
sdkRegistryMax: 100,
|
||||
ocrCapabilityId: "ocr.page.extract.chandra-ocr-2.v1",
|
||||
corsOrigins: [],
|
||||
};
|
||||
const chain = overrides?.chain ?? ({ queryUtxosByAddress: async () => [] } as unknown as GatewayDeps["chain"]);
|
||||
const registry = new SdkRegistry({
|
||||
chain,
|
||||
indexerUrl: config.indexerUrl,
|
||||
networkId: config.networkId,
|
||||
masterKeyHex: config.masterKeyHex,
|
||||
max: config.sdkRegistryMax,
|
||||
});
|
||||
const defaultFetch = (async () => new Response("[]", { status: 200 })) as unknown as typeof globalThis.fetch;
|
||||
return { config, store, chain, registry, fetchFn: fetchFn ?? defaultFetch };
|
||||
}
|
||||
|
||||
/** Insert a key row (demo or funded) and return the raw bearer token. */
|
||||
function makeKey(deps: GatewayDeps, demo: 0 | 1): string {
|
||||
const rawKey = `vk-${randomBytes(24).toString("hex")}`;
|
||||
const privHex = genPrivKeyHex();
|
||||
const walletKey = deriveWalletKey(privHex, 0);
|
||||
const sealed = seal(privHex, MASTER);
|
||||
deps.store.insertKey({
|
||||
id: randomUUID(),
|
||||
key_hash: hashApiKey(rawKey),
|
||||
key_prefix: rawKey.slice(0, 12),
|
||||
label: demo ? "demo" : "funded",
|
||||
wallet_pkh: walletKey.pubKeyHash,
|
||||
deposit_address: walletKey.address,
|
||||
enc_priv_nonce: sealed.nonce,
|
||||
enc_priv_ct: sealed.ct,
|
||||
enc_priv_tag: sealed.tag,
|
||||
master_key_version: 1,
|
||||
created_at: Date.now(),
|
||||
demo,
|
||||
});
|
||||
return rawKey;
|
||||
}
|
||||
|
||||
function validBody() {
|
||||
return { image_b64: IMG, mime: "image/png", output_format: "markdown" };
|
||||
}
|
||||
|
||||
describe("POST /v1/ocr/extract — auth + demo gating", () => {
|
||||
it("401 without a bearer key, OpenAI error shape", async () => {
|
||||
const res = await request(createApp(makeDeps()))
|
||||
.post("/v1/ocr/extract").send(validBody());
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error?.type).toBe("authentication_error");
|
||||
});
|
||||
|
||||
it("403 demo_key_unsupported for demo keys", async () => {
|
||||
const deps = makeDeps();
|
||||
const rawKey = makeKey(deps, 1);
|
||||
const res = await request(createApp(deps))
|
||||
.post("/v1/ocr/extract")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send(validBody());
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error?.code).toBe("demo_key_unsupported");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v1/ocr/extract — body validation", () => {
|
||||
async function post(body: unknown) {
|
||||
const deps = makeDeps();
|
||||
const rawKey = makeKey(deps, 0);
|
||||
return request(createApp(deps))
|
||||
.post("/v1/ocr/extract")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send(body as object);
|
||||
}
|
||||
|
||||
it("400 invalid_image on missing/empty/data-URL image_b64", async () => {
|
||||
for (const image_b64 of [undefined, "", `data:image/png;base64,${IMG}`]) {
|
||||
const res = await post({ ...validBody(), image_b64 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error?.code).toBe("invalid_image");
|
||||
}
|
||||
});
|
||||
|
||||
it("400 invalid_mime on unsupported mime", async () => {
|
||||
const res = await post({ ...validBody(), mime: "image/gif" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error?.code).toBe("invalid_mime");
|
||||
});
|
||||
|
||||
it("400 invalid_output_format on unsupported format", async () => {
|
||||
const res = await post({ ...validBody(), output_format: "yaml" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error?.code).toBe("invalid_output_format");
|
||||
});
|
||||
|
||||
it("output_format defaults to markdown (reaches supplier lookup)", async () => {
|
||||
const res = await post({ image_b64: IMG, mime: "image/png" });
|
||||
// Passes validation, then the empty indexer answer → 404 model_not_found.
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error?.code).toBe("model_not_found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v1/ocr/extract — supplier lookup + body limit", () => {
|
||||
it("404 model_not_found when no supplier advertises the capability", async () => {
|
||||
const deps = makeDeps();
|
||||
const rawKey = makeKey(deps, 0);
|
||||
const res = await request(createApp(deps))
|
||||
.post("/v1/ocr/extract")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send(validBody());
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error?.code).toBe("model_not_found");
|
||||
});
|
||||
|
||||
it("queries the indexer with the configured ocr capability id", async () => {
|
||||
let queried = "";
|
||||
const fetchFn = (async (url: unknown) => {
|
||||
queried = String(url);
|
||||
return new Response("[]", { status: 200 });
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
const deps = makeDeps(fetchFn);
|
||||
const rawKey = makeKey(deps, 0);
|
||||
await request(createApp(deps))
|
||||
.post("/v1/ocr/extract")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send(validBody());
|
||||
expect(queried).toContain("capability_id=ocr.page.extract.chandra-ocr-2.v1");
|
||||
});
|
||||
|
||||
it("accepts a 2MB body (over the 1mb global limit — OCR route has its own parser)", async () => {
|
||||
const deps = makeDeps();
|
||||
const rawKey = makeKey(deps, 0);
|
||||
const big = "A".repeat(2_000_000);
|
||||
const res = await request(createApp(deps))
|
||||
.post("/v1/ocr/extract")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send({ ...validBody(), image_b64: big });
|
||||
expect(res.status).not.toBe(413);
|
||||
// Valid base64 + no suppliers → 404 model_not_found.
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("chat completions keep the 1mb ceiling (413 on a 2MB body)", async () => {
|
||||
const deps = makeDeps();
|
||||
const rawKey = makeKey(deps, 0);
|
||||
const big = "A".repeat(2_000_000);
|
||||
const res = await request(createApp(deps))
|
||||
.post("/openai/v1/chat/completions")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send({ model: "qwen", messages: [{ role: "user", content: big }] });
|
||||
expect(res.status).toBe(413);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── venueAttribution — pure-function header sanitization (I3) ──────────────
|
||||
//
|
||||
// grep for "first-party-brokered" used to return only source lines: nothing
|
||||
// exercised venueAttribution's regex gates or RECEIPT_LABELS_V0 reaching the
|
||||
// wire. These two blocks close that gap.
|
||||
|
||||
function fakeReq(headers: Record<string, string>): Parameters<typeof venueAttribution>[0] {
|
||||
return { header: (name: string) => headers[name] } as unknown as Parameters<typeof venueAttribution>[0];
|
||||
}
|
||||
|
||||
describe("venueAttribution — header sanitization", () => {
|
||||
it("passes through a valid venue slug and a valid lowercase hash", () => {
|
||||
const hash = "a".repeat(64);
|
||||
const out = venueAttribution(fakeReq({ "X-Venue": "apify", "X-Run-User-Hash": hash }));
|
||||
expect(out).toEqual({ venue: "apify", runUserHash: hash });
|
||||
});
|
||||
|
||||
it("lowercases a valid but uppercase hex hash", () => {
|
||||
const out = venueAttribution(fakeReq({ "X-Venue": "apify", "X-Run-User-Hash": "A".repeat(64) }));
|
||||
expect(out.runUserHash).toBe("a".repeat(64));
|
||||
});
|
||||
|
||||
it("drops an invalid venue to empty string (uppercase, bad chars, oversized)", () => {
|
||||
for (const bad of ["Apify", "apify!", "apify_venue", "a".repeat(33)]) {
|
||||
const out = venueAttribution(fakeReq({ "X-Venue": bad, "X-Run-User-Hash": "a".repeat(64) }));
|
||||
expect(out.venue).toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
it("drops an invalid run-user-hash to empty string (wrong length, non-hex)", () => {
|
||||
for (const bad of ["a".repeat(63), "a".repeat(65), "z".repeat(64), "not-hex-at-all"]) {
|
||||
const out = venueAttribution(fakeReq({ "X-Venue": "apify", "X-Run-User-Hash": bad }));
|
||||
expect(out.runUserHash).toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults both fields to empty string when headers are missing", () => {
|
||||
const out = venueAttribution(fakeReq({}));
|
||||
expect(out).toEqual({ venue: "", runUserHash: "" });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Happy path — receipt disclosure label reaches the response (I3) ────────
|
||||
|
||||
describe("POST /v1/ocr/extract — happy path", () => {
|
||||
it("200 response x_vector.receipt_labels contains first-party-brokered", async () => {
|
||||
const fetchFn = (async (url: unknown) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/suppliers")) {
|
||||
return new Response(JSON.stringify([OCR_SUPPLIER]), { status: 200 });
|
||||
}
|
||||
return new Response("[]", { status: 200 });
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
|
||||
const deps = makeDeps(fetchFn, { chain: fundedChain });
|
||||
const rawKey = makeKey(deps, 0);
|
||||
const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!;
|
||||
|
||||
// Stub only the chain-touching supplier call; escrow ref shape matches
|
||||
// what resolveSubmittedRef's mock (module-level, top of file) expects.
|
||||
const ctx = deps.registry.getContext(keyRow);
|
||||
const submitOcr = vi.fn(async () => ({
|
||||
output_format: "markdown",
|
||||
content: "extracted text",
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||
receipt: {
|
||||
prompt_hash: "1b".repeat(32),
|
||||
response_hash: "2c".repeat(32),
|
||||
model: "chandra-ocr-2",
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
wallclock_ms: Date.now(),
|
||||
supplier_pkh: "s3",
|
||||
escrow_ref: `${"d".repeat(64)}#0`,
|
||||
},
|
||||
receiptSignature: "sig".repeat(20),
|
||||
escrowRef: { txHash: "d".repeat(64), index: 0 },
|
||||
}));
|
||||
(ctx.sdk as { submitOcr: unknown }).submitOcr = submitOcr;
|
||||
|
||||
const res = await request(createApp(deps))
|
||||
.post("/v1/ocr/extract")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send(validBody());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(submitOcr).toHaveBeenCalledTimes(1);
|
||||
expect(res.body.x_vector.receipt_labels).toContain("first-party-brokered");
|
||||
// escrow_ref in the response comes from submitOcr's returned escrowRef
|
||||
// (the ORIGINAL Open ref), not the mocked resolveSubmittedRef's answer —
|
||||
// see settle.ts's docstring on why those two refs differ on-chain.
|
||||
expect(res.body.x_vector.escrow_ref).toBe(`${"d".repeat(64)}#0`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Failure path — escrow ref survives a post-escrow SupplierError (I2) ────
|
||||
//
|
||||
// submitOcr posts the escrow, THEN calls the supplier. A SupplierError
|
||||
// (timeout, network failure, non-2xx, malformed response, receipt mismatch)
|
||||
// means the escrow already landed on chain even though submitOcr rejects —
|
||||
// per the OCR route's 120s supplier budget, this is likely the MOST common
|
||||
// paid-but-failed shape. Before this fix, escrowRefStr was only assigned
|
||||
// from submitOcr's *return value*, so this exact case recorded escrow_ref:
|
||||
// null. The route now also listens for the SDK's "escrow_posted" progress
|
||||
// event around each attempt, so the ref survives regardless of whether
|
||||
// submitOcr goes on to resolve or reject.
|
||||
|
||||
describe("POST /v1/ocr/extract — escrow ref survives a post-escrow SupplierError", () => {
|
||||
it("records the escrow_ref from the escrow_posted progress event, not null, when submitOcr posts an escrow then rejects", async () => {
|
||||
const fetchFn = (async (url: unknown) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/suppliers")) {
|
||||
return new Response(JSON.stringify([OCR_SUPPLIER]), { status: 200 });
|
||||
}
|
||||
return new Response("[]", { status: 200 });
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
|
||||
const deps = makeDeps(fetchFn, { chain: fundedChain });
|
||||
const rawKey = makeKey(deps, 0);
|
||||
const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!;
|
||||
|
||||
// Same monkeypatch pattern as the happy-path test above: fetch the real
|
||||
// per-key Marketplace instance from the registry BEFORE the request so
|
||||
// its real EventEmitter (on/off/emit) stays live, then replace only
|
||||
// submitOcr. The stub mirrors what Marketplace.submitOcr actually does
|
||||
// on this failure shape: emit "escrow_posted" the instant the tx
|
||||
// confirms, then throw once the supplier call fails.
|
||||
const ctx = deps.registry.getContext(keyRow);
|
||||
const knownEscrowRef = `${"9".repeat(64)}#0`;
|
||||
const submitOcr = vi.fn(async () => {
|
||||
ctx.sdk.emit("progress", { type: "escrow_posted", escrow_ref: knownEscrowRef });
|
||||
throw new SupplierError("timeout", { message: "supplier did not respond within budget" });
|
||||
});
|
||||
(ctx.sdk as { submitOcr: unknown }).submitOcr = submitOcr;
|
||||
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const res = await request(createApp(deps))
|
||||
.post("/v1/ocr/extract")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send(validBody());
|
||||
// Snapshot the recorded calls BEFORE restoring — mockRestore() also
|
||||
// clears .mock.calls (it runs mockReset()/mockClear() first), so
|
||||
// reading logSpy.mock.calls after restore always sees [].
|
||||
const loggedLines = logSpy.mock.calls.map((args) => String(args[0]));
|
||||
logSpy.mockRestore();
|
||||
|
||||
expect(submitOcr).toHaveBeenCalledTimes(1);
|
||||
// toGatewayError maps SupplierError("timeout") to 504 supplier_timeout —
|
||||
// confirms the request actually took the failure path this test targets.
|
||||
expect(res.status).toBe(504);
|
||||
expect(res.body.error?.code).toBe("supplier_timeout");
|
||||
|
||||
// The reconciliation spine: the usage row recorded in the catch block
|
||||
// must carry the real ref, not null.
|
||||
const rows = deps.store.listUsage(keyRow.id, 10);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].status).toBe("failed");
|
||||
expect(rows[0].escrow_ref).toBe(knownEscrowRef);
|
||||
|
||||
// The structured ocr_failed log line (wedge-ledger emitter input) must
|
||||
// carry the same ref.
|
||||
const failedLine = loggedLines
|
||||
.filter((line) => line.includes('"evt":"ocr_failed"'))
|
||||
.map((line) => JSON.parse(line) as { escrow_ref: string | null });
|
||||
expect(failedLine).toHaveLength(1);
|
||||
expect(failedLine[0].escrow_ref).toBe(knownEscrowRef);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Busy pre-check - never post an escrow to a candidate that is busy ──────
|
||||
//
|
||||
// runOcrOneShot now checks each candidate's live /status immediately before
|
||||
// calling submitOcr for it (mirrors sessions.ts's startChat pre-check):
|
||||
// "working" means busy and moves on to the next candidate WITHOUT posting an
|
||||
// escrow; anything else (free, unreachable, malformed, slow) is treated as
|
||||
// usable. submitOcr is the escrow-posting path in these tests (same
|
||||
// monkeypatch pattern as the suites above), so asserting its call count is
|
||||
// exactly how "no escrow was posted" is observed.
|
||||
|
||||
const OCR_SUPPLIER_A = {
|
||||
...OCR_SUPPLIER,
|
||||
utxo_ref: `${"aa".repeat(32)}#0`,
|
||||
supplier_pkh: "sA",
|
||||
endpoint_url: "http://sup-a",
|
||||
};
|
||||
const OCR_SUPPLIER_B = {
|
||||
...OCR_SUPPLIER,
|
||||
utxo_ref: `${"bb".repeat(32)}#0`,
|
||||
supplier_pkh: "sB",
|
||||
endpoint_url: "http://sup-b",
|
||||
};
|
||||
|
||||
/** Indexer /suppliers lookup plus a per-endpoint /status responder, on one
|
||||
* fetchFn - everything else falls back to the suite's usual empty array. */
|
||||
function makeFetchWithStatus(
|
||||
suppliers: Array<typeof OCR_SUPPLIER>,
|
||||
statusByEndpoint: Record<string, () => Response>,
|
||||
): typeof globalThis.fetch {
|
||||
return (async (url: unknown) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/suppliers")) {
|
||||
return new Response(JSON.stringify(suppliers), { status: 200 });
|
||||
}
|
||||
for (const [endpoint, respond] of Object.entries(statusByEndpoint)) {
|
||||
if (u === `${endpoint}/status`) return respond();
|
||||
}
|
||||
return new Response("[]", { status: 200 });
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
/** Same successful submitOcr shape as the happy-path suite above, reused
|
||||
* across the busy pre-check tests. */
|
||||
function makeSubmitOcrStub() {
|
||||
return vi.fn(async (_opts: { advertRef: { txHash: string; index: number } }) => ({
|
||||
output_format: "markdown",
|
||||
content: "extracted text",
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||
receipt: {
|
||||
prompt_hash: "1b".repeat(32),
|
||||
response_hash: "2c".repeat(32),
|
||||
model: "chandra-ocr-2",
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
wallclock_ms: Date.now(),
|
||||
supplier_pkh: "s3",
|
||||
escrow_ref: `${"d".repeat(64)}#0`,
|
||||
},
|
||||
receiptSignature: "sig".repeat(20),
|
||||
escrowRef: { txHash: "d".repeat(64), index: 0 },
|
||||
}));
|
||||
}
|
||||
|
||||
describe("POST /v1/ocr/extract - busy pre-check", () => {
|
||||
it("a busy supplier is never sent an escrow; the route returns the busy response", async () => {
|
||||
const fetchFn = makeFetchWithStatus([OCR_SUPPLIER], {
|
||||
"http://sup": () => new Response(JSON.stringify({ status: "working" }), { status: 200 }),
|
||||
});
|
||||
const deps = makeDeps(fetchFn, { chain: fundedChain });
|
||||
const rawKey = makeKey(deps, 0);
|
||||
const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!;
|
||||
|
||||
const ctx = deps.registry.getContext(keyRow);
|
||||
const submitOcr = makeSubmitOcrStub();
|
||||
(ctx.sdk as { submitOcr: unknown }).submitOcr = submitOcr;
|
||||
|
||||
const res = await request(createApp(deps))
|
||||
.post("/v1/ocr/extract")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send(validBody());
|
||||
|
||||
// The escrow-posting path was never invoked.
|
||||
expect(submitOcr).not.toHaveBeenCalled();
|
||||
// toGatewayError maps SupplierError("supplier_busy") to 503 overloaded -
|
||||
// the same busy response a post-escrow 409 would have produced.
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.error?.code).toBe("overloaded");
|
||||
|
||||
const rows = deps.store.listUsage(keyRow.id, 10);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].status).toBe("failed");
|
||||
expect(rows[0].escrow_ref).toBeNull();
|
||||
});
|
||||
|
||||
it("a free supplier proceeds to post and settle exactly as before", async () => {
|
||||
const fetchFn = makeFetchWithStatus([OCR_SUPPLIER], {
|
||||
"http://sup": () => new Response(JSON.stringify({ status: "free" }), { status: 200 }),
|
||||
});
|
||||
const deps = makeDeps(fetchFn, { chain: fundedChain });
|
||||
const rawKey = makeKey(deps, 0);
|
||||
const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!;
|
||||
|
||||
const ctx = deps.registry.getContext(keyRow);
|
||||
const submitOcr = makeSubmitOcrStub();
|
||||
(ctx.sdk as { submitOcr: unknown }).submitOcr = submitOcr;
|
||||
|
||||
const res = await request(createApp(deps))
|
||||
.post("/v1/ocr/extract")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send(validBody());
|
||||
|
||||
expect(submitOcr).toHaveBeenCalledTimes(1);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.x_vector.receipt_labels).toContain("first-party-brokered");
|
||||
});
|
||||
|
||||
it("falls back to the second candidate when the first is busy, posting exactly one escrow", async () => {
|
||||
const fetchFn = makeFetchWithStatus([OCR_SUPPLIER_A, OCR_SUPPLIER_B], {
|
||||
"http://sup-a": () => new Response(JSON.stringify({ status: "working" }), { status: 200 }),
|
||||
"http://sup-b": () => new Response(JSON.stringify({ status: "free" }), { status: 200 }),
|
||||
});
|
||||
const deps = makeDeps(fetchFn, { chain: fundedChain });
|
||||
const rawKey = makeKey(deps, 0);
|
||||
const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!;
|
||||
|
||||
const ctx = deps.registry.getContext(keyRow);
|
||||
const submitOcr = makeSubmitOcrStub();
|
||||
(ctx.sdk as { submitOcr: unknown }).submitOcr = submitOcr;
|
||||
|
||||
const res = await request(createApp(deps))
|
||||
.post("/v1/ocr/extract")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send(validBody());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Exactly one escrow posted, and it is the second (free) candidate's -
|
||||
// the busy first candidate was skipped, not retried after failing.
|
||||
expect(submitOcr).toHaveBeenCalledTimes(1);
|
||||
expect(submitOcr.mock.calls[0][0].advertRef).toEqual({ txHash: "bb".repeat(32), index: 0 });
|
||||
});
|
||||
|
||||
it("a status check that errors does not block the attempt", async () => {
|
||||
const fetchFn = makeFetchWithStatus([OCR_SUPPLIER], {
|
||||
"http://sup": () => {
|
||||
// Simulates both a network error and an aborted (timed-out) request -
|
||||
// isSupplierBusy's catch block treats every failure mode the same
|
||||
// way: unknown, not busy, proceed. A real AbortSignal timeout is not
|
||||
// exercised here to keep the suite fast; the outcome is identical.
|
||||
throw new Error("simulated network failure reaching /status");
|
||||
},
|
||||
});
|
||||
const deps = makeDeps(fetchFn, { chain: fundedChain });
|
||||
const rawKey = makeKey(deps, 0);
|
||||
const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!;
|
||||
|
||||
const ctx = deps.registry.getContext(keyRow);
|
||||
const submitOcr = makeSubmitOcrStub();
|
||||
(ctx.sdk as { submitOcr: unknown }).submitOcr = submitOcr;
|
||||
|
||||
const res = await request(createApp(deps))
|
||||
.post("/v1/ocr/extract")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send(validBody());
|
||||
|
||||
// The candidate was still tried despite the failed pre-check.
|
||||
expect(submitOcr).toHaveBeenCalledTimes(1);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,7 @@ function makeSweepDeps(opts: { chatSettleMode: "full" | "ticket"; escrows: Escro
|
||||
chatSettleMode: opts.chatSettleMode,
|
||||
walletHealthIntervalMs: 600_000,
|
||||
sdkRegistryMax: 100,
|
||||
ocrCapabilityId: "ocr.page.extract.chandra-ocr-2.v1",
|
||||
corsOrigins: [],
|
||||
};
|
||||
const chain = { queryUtxo: async () => null } as unknown as GatewayDeps["chain"];
|
||||
|
||||
@@ -208,8 +208,16 @@ describe("onchain/preflight", () => {
|
||||
});
|
||||
|
||||
it("preflight requires balance AND collateral", async () => {
|
||||
const cost = { priceLovelace: 2_000_000n, buyerBondLovelace: 1_000_000n, supplierBondLovelace: 1_000_000n };
|
||||
// required = 2 + 1 + 1 + 5 (collateral) + 2 (fee) = 11 ADA
|
||||
const cost = {
|
||||
capabilityId: "llm.text.generate.v1",
|
||||
priceLovelace: 2_000_000n,
|
||||
buyerBondLovelace: 1_000_000n,
|
||||
supplierBondLovelace: 1_000_000n,
|
||||
};
|
||||
// required = escrowLockFloor(2 + 1 + 1) + 5 (collateral) + 2 (fee) = 11 ADA
|
||||
// — at this economic total (4 ADA) the Submitted-state min-ada floor for a
|
||||
// short capability id (~2.2 ADA) stays well under the raw total, so the
|
||||
// floor is a no-op here; tx-minada-floor.test.ts covers the lifted case.
|
||||
const fakeChain = { queryUtxosByAddress: async () => [pure(20_000_000n)] } as any;
|
||||
const ok = await preflight(fakeChain, "a", cost);
|
||||
expect(ok.ok).toBe(true);
|
||||
|
||||
@@ -29,6 +29,8 @@ RUN apk add --no-cache python3 make g++
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
|
||||
# pnpm-lock.yaml's patchedDependencies hash the patch files at install time.
|
||||
COPY patches ./patches
|
||||
COPY packages/shared/package.json ./packages/shared/package.json
|
||||
COPY indexer/package.json ./indexer/package.json
|
||||
COPY indexer-ui/package.json ./indexer-ui/package.json
|
||||
|
||||
257
packages/shared/src/tx/escrow/postOcrEscrow.ts
Normal file
257
packages/shared/src/tx/escrow/postOcrEscrow.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* tx/escrow/postOcrEscrow.ts — PostEscrow tx builder for model-scoped OCR
|
||||
* capabilities (`ocr.page.extract.<model-slug>.v1`, one page per job).
|
||||
*
|
||||
* Mirrors `postTtsEscrow.ts` but commits an OCR-shaped prompt_hash over the
|
||||
* canonicalised request envelope `{image_b64, mime, output_format}` — the
|
||||
* SAME object the supplier hashes when validating the incoming
|
||||
* POST /v1/ocr/extract body. Any mismatch on either side fails the
|
||||
* supplier-side prompt_mismatch gate.
|
||||
*
|
||||
* The image travels off-chain (buyer → supplier HTTP body); the chain only
|
||||
* ever carries prompt_hash. Never place image bytes or extracted text in a
|
||||
* datum.
|
||||
*
|
||||
* Off-chain invariants (mirror TTS's, with OCR-shaped body validation):
|
||||
* 1. advert UTxO exists at advertRef
|
||||
* 2. advert datum.status === "Active"
|
||||
* 3. payment_lovelace === advert.price_lovelace
|
||||
* 4. buyerKey.pubKeyHash !== advert.supplier_pkh
|
||||
* 5. body.image_b64 is a non-empty plain-base64 string within size bounds
|
||||
* (no data-URL prefix — the mime rides in its own field)
|
||||
* 6. body.mime ∈ ALLOWED_OCR_MIMES
|
||||
* 7. body.output_format ∈ ALLOWED_OCR_OUTPUT_FORMATS
|
||||
*/
|
||||
|
||||
import * as nodeCrypto from "crypto";
|
||||
import type { ChainProvider, OutputReference } from "../../chain/ChainProvider.js";
|
||||
import type { EscrowDatum } from "../../cbor/types.js";
|
||||
import { decodeAdvertDatum } from "../../cbor/AdvertDatum.js";
|
||||
import { encodeEscrowDatum } from "../../cbor/EscrowDatum.js";
|
||||
import { canonicalize } from "../../cbor/canonical.js";
|
||||
import type { WalletKey, PostEscrowBuildResult } from "../types.js";
|
||||
import { TxConstructionError } from "../types.js";
|
||||
import { loadBlueprint } from "../blueprint.js";
|
||||
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
|
||||
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
|
||||
import { detectCborBackend } from "../internal/cborBackend.js";
|
||||
import { escrowLockFloor } from "../internal/minAdaFloor.js";
|
||||
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
||||
|
||||
/** OCR request envelope. The buyer-side hash and the supplier-side hash
|
||||
* MUST agree on this exact set of keys, in this exact JCS-canonical
|
||||
* encoding, or the supplier will reject with `prompt_mismatch`. */
|
||||
export interface OcrRequest {
|
||||
/** Plain base64 of the page image bytes. No `data:` prefix. */
|
||||
image_b64: string;
|
||||
/** Image content type. */
|
||||
mime: string;
|
||||
/** Requested extraction output shape. */
|
||||
output_format: string;
|
||||
}
|
||||
|
||||
export const ALLOWED_OCR_MIMES = new Set([
|
||||
"image/png", "image/jpeg", "image/webp",
|
||||
]);
|
||||
export const ALLOWED_OCR_OUTPUT_FORMATS = new Set(["markdown", "html", "json"]);
|
||||
|
||||
/**
|
||||
* Upper bound on the base64 payload, in characters (~9 MB of image bytes).
|
||||
* A 300-DPI page scan lands around 0.5–3 MB binary; this cap protects the
|
||||
* supplier's JSON body parser and the off-chain transport, not the chain
|
||||
* (the chain only sees the 32-byte hash).
|
||||
*/
|
||||
export const MAX_OCR_IMAGE_B64_CHARS = 12_000_000;
|
||||
|
||||
/** Plain base64 (standard alphabet, optional padding). Rejects data URLs,
|
||||
* whitespace and base64url — one canonical encoding, one hash. */
|
||||
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||
|
||||
export interface PostOcrEscrowParams {
|
||||
chain: ChainProvider;
|
||||
buyerKey: WalletKey;
|
||||
advertRef: OutputReference;
|
||||
request: OcrRequest;
|
||||
payment_lovelace: bigint;
|
||||
}
|
||||
|
||||
function sha256Utf8Hex(s: string): string {
|
||||
return nodeCrypto.createHash("sha256").update(s, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
/** Hash function shared between buyer (here) and supplier (server.ts).
|
||||
* Exported so unit tests on either side can pin the canonicalisation. */
|
||||
export function ocrPromptHash(req: OcrRequest): string {
|
||||
return sha256Utf8Hex(canonicalize({
|
||||
image_b64: req.image_b64,
|
||||
mime: req.mime,
|
||||
output_format: req.output_format,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Body validation shared conceptually with the supplier route. Throws
|
||||
* TxConstructionError with a machine-readable reason. */
|
||||
export function validateOcrRequest(request: OcrRequest): void {
|
||||
if (typeof request?.image_b64 !== "string" || request.image_b64.length === 0) {
|
||||
throw new TxConstructionError("image required",
|
||||
"request.image_b64 must be a non-empty base64 string");
|
||||
}
|
||||
if (request.image_b64.length > MAX_OCR_IMAGE_B64_CHARS) {
|
||||
throw new TxConstructionError("image too large",
|
||||
`request.image_b64 exceeds ${MAX_OCR_IMAGE_B64_CHARS} chars`);
|
||||
}
|
||||
if (!BASE64_RE.test(request.image_b64)) {
|
||||
throw new TxConstructionError("image not base64",
|
||||
"request.image_b64 must be plain base64 (no data: prefix, no whitespace)");
|
||||
}
|
||||
if (!ALLOWED_OCR_MIMES.has(request.mime)) {
|
||||
throw new TxConstructionError("mime invalid",
|
||||
`mime must be one of: ${[...ALLOWED_OCR_MIMES].join(", ")}`);
|
||||
}
|
||||
if (!ALLOWED_OCR_OUTPUT_FORMATS.has(request.output_format)) {
|
||||
throw new TxConstructionError("output_format invalid",
|
||||
`output_format must be one of: ${[...ALLOWED_OCR_OUTPUT_FORMATS].join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildPostOcrEscrowTx(
|
||||
params: PostOcrEscrowParams,
|
||||
): Promise<PostEscrowBuildResult> {
|
||||
const { chain, buyerKey, advertRef, request, payment_lovelace } = params;
|
||||
|
||||
// 5–7. Body validation before any chain access.
|
||||
validateOcrRequest(request);
|
||||
|
||||
// 1. Advert UTxO must exist.
|
||||
const advertUtxo = await chain.queryUtxo(advertRef);
|
||||
if (advertUtxo === null) {
|
||||
throw new TxConstructionError(
|
||||
"advert ref not on chain",
|
||||
`no UTxO at ${advertRef.txHash}#${advertRef.index}`,
|
||||
);
|
||||
}
|
||||
if (!advertUtxo.datumHex) {
|
||||
throw new TxConstructionError(
|
||||
"advert datum missing",
|
||||
`UTxO ${advertRef.txHash}#${advertRef.index} has no inline datum`,
|
||||
);
|
||||
}
|
||||
|
||||
const advertDatum = decodeAdvertDatum(advertUtxo.datumHex);
|
||||
|
||||
// 2. Advert must be Active.
|
||||
if (advertDatum.status !== "Active") {
|
||||
throw new TxConstructionError(
|
||||
"advert is retired",
|
||||
`advert.status is ${advertDatum.status}, expected Active`,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Payment must equal advertised price.
|
||||
if (payment_lovelace !== advertDatum.price_lovelace) {
|
||||
throw new TxConstructionError(
|
||||
"payment must equal advertised price",
|
||||
`payment ${payment_lovelace} != advert.price ${advertDatum.price_lovelace}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Buyer cannot be supplier.
|
||||
if (buyerKey.pubKeyHash === advertDatum.supplier_pkh) {
|
||||
throw new TxConstructionError(
|
||||
"buyer cannot be supplier",
|
||||
`buyerKey pkh ${buyerKey.pubKeyHash} equals advert.supplier_pkh`,
|
||||
);
|
||||
}
|
||||
|
||||
// Time source: same convention as chat/TTS.
|
||||
const tipSlot = await chain.tip();
|
||||
const isLive = detectCborBackend(chain) === "live";
|
||||
const postedAt = isLive ? Date.now() : mockSlotToWallclockMs(tipSlot);
|
||||
const deliverBy = postedAt + advertDatum.max_processing_ms + NETWORK_BUFFER_MS;
|
||||
|
||||
const requestSpecCanonical = canonicalize({
|
||||
capability_id: advertDatum.capability_id,
|
||||
max_output_tokens: advertDatum.max_output_tokens,
|
||||
model: advertDatum.model,
|
||||
});
|
||||
const requestSpecHash = sha256Utf8Hex(requestSpecCanonical);
|
||||
const promptHash = ocrPromptHash(request);
|
||||
|
||||
const economicTotal =
|
||||
advertDatum.price_lovelace +
|
||||
advertDatum.buyer_bond_lovelace +
|
||||
advertDatum.supplier_bond_lovelace;
|
||||
|
||||
const escrowDatum: EscrowDatum = {
|
||||
buyer_pkh: buyerKey.pubKeyHash,
|
||||
supplier_pkh: advertDatum.supplier_pkh,
|
||||
advert_ref: advertRef,
|
||||
capability_id: advertDatum.capability_id,
|
||||
request_spec_hash: requestSpecHash,
|
||||
prompt_hash: promptHash,
|
||||
payment_lovelace: advertDatum.price_lovelace,
|
||||
buyer_bond_lovelace: advertDatum.buyer_bond_lovelace,
|
||||
supplier_bond_lovelace: advertDatum.supplier_bond_lovelace,
|
||||
deliver_by: deliverBy,
|
||||
posted_at: postedAt,
|
||||
submitted_at: null,
|
||||
result_receipt_hash: null,
|
||||
state: "Open",
|
||||
};
|
||||
|
||||
// Lock enough that the escrow output satisfies min-ada in its LARGEST
|
||||
// future state (Submitted) - value_equal on Claim/Submit forbids any
|
||||
// later bump (2026-08-07 min-ada incident).
|
||||
const totalLocked = escrowLockFloor(escrowDatum, economicTotal);
|
||||
|
||||
// Live path: reuse the capability-agnostic live builder (messages is
|
||||
// void-marked there; the on-chain commitment is the escrowDatum, which
|
||||
// carries our OCR prompt_hash).
|
||||
if (isLive) {
|
||||
const liveCborPath = "../internal/liveCbor.js";
|
||||
const { buildLiveTxForEscrow } = await import(/* @vite-ignore */ liveCborPath);
|
||||
return buildLiveTxForEscrow({
|
||||
chain: chain as LiveOgmiosProvider,
|
||||
buyerKey,
|
||||
advertRef,
|
||||
messages: [],
|
||||
escrowDatum,
|
||||
totalLocked,
|
||||
deliverBy,
|
||||
postedAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Mock backend path (synthetic JSON-in-hex tx body).
|
||||
const blueprint = loadBlueprint();
|
||||
const escrowAddress = blueprint.escrowScriptAddress(0);
|
||||
|
||||
const body = {
|
||||
type: "post-escrow",
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{
|
||||
ref: { txHash: "$self", index: 0 },
|
||||
address: escrowAddress,
|
||||
lovelace: totalLocked,
|
||||
assets: {},
|
||||
datumHex: encodeEscrowDatum(escrowDatum),
|
||||
scriptRef: null,
|
||||
},
|
||||
],
|
||||
requiredSigners: [buyerKey.pubKeyHash],
|
||||
validityRange: { lowerBoundMs: postedAt, upperBoundMs: deliverBy },
|
||||
meta: { script_hash: blueprint.escrowScriptHash, advert_ref: advertRef },
|
||||
};
|
||||
|
||||
const txCborHex = encodeTxBody(body);
|
||||
const expectedTxHash = sha256Hex(txCborHex);
|
||||
|
||||
await chain.submitTx(txCborHex);
|
||||
|
||||
return {
|
||||
txCborHex,
|
||||
expectedTxHash,
|
||||
escrowOutputRef: { txHash: expectedTxHash, index: 0 },
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export { TxConstructionError } from "./types.js";
|
||||
export { normalizeChatMessage, chatMessagesEquivalent, toolCallsEqual } from "./chatMessage.js";
|
||||
export { mockSlotToWallclockMs, mockWallclockMsToSlot, NETWORK_BUFFER_MS } from "./internal/constants.js";
|
||||
export { detectCborBackend, type CborBackend } from "./internal/cborBackend.js";
|
||||
export { escrowLockFloor, minAdaForEscrowDatum } from "./internal/minAdaFloor.js";
|
||||
export type { Signer } from "./signer.js";
|
||||
export { MockSigner } from "./signer.js";
|
||||
export type { Blueprint } from "./blueprint.js";
|
||||
@@ -37,6 +38,15 @@ export {
|
||||
} from "./escrow/postTtsEscrow.js";
|
||||
export type { PostChatEscrowParams } from "./escrow/postChatEscrow.js";
|
||||
export { buildPostChatEscrowTx, chatSessionPromptHash } from "./escrow/postChatEscrow.js";
|
||||
export type { PostOcrEscrowParams, OcrRequest } from "./escrow/postOcrEscrow.js";
|
||||
export {
|
||||
buildPostOcrEscrowTx,
|
||||
ocrPromptHash,
|
||||
validateOcrRequest,
|
||||
ALLOWED_OCR_MIMES,
|
||||
ALLOWED_OCR_OUTPUT_FORMATS,
|
||||
MAX_OCR_IMAGE_B64_CHARS,
|
||||
} from "./escrow/postOcrEscrow.js";
|
||||
export type { ClaimParams } from "./escrow/claim.js";
|
||||
export { buildClaimTx } from "./escrow/claim.js";
|
||||
export type { SubmitParams } from "./escrow/submit.js";
|
||||
|
||||
@@ -2,9 +2,22 @@
|
||||
* planConsolidate.ts — pure pre-flight planner for the wallet-consolidate flow.
|
||||
*
|
||||
* Given a wallet's current UTxO set + a target collateral lovelace, decide
|
||||
* whether to consolidate (many → 2), split (1 → 2), or skip (already at
|
||||
* {≥collateral, working}). The same downstream tx-builder handles
|
||||
* consolidate and split — only the "reason" tag differs for logging.
|
||||
* whether the wallet needs reshaping or is already usable for script spends.
|
||||
*
|
||||
* Healthy is a STRUCTURAL predicate, not a UTxO count (F7, 2026-08-08):
|
||||
* (a) some pure-lovelace UTxO >= collateralLovelace exists (the collateral
|
||||
* candidate lucid's setCollateral needs), AND
|
||||
* (b) at least MIN_WORKING_LOVELACE sits OUTSIDE that candidate (fee and
|
||||
* spend money that is not the candidate), AND
|
||||
* (c) fragmentation is bounded (utxos.length <= maxUtxos).
|
||||
*
|
||||
* The earlier revision returned `already-healthy` only for EXACTLY 2 UTxOs.
|
||||
* The consolidate tx itself lands 3 (collateral + working + the fee-reserve
|
||||
* remainder as change), so the shape consolidation produced could never
|
||||
* satisfy its own health check — the wallet-health ticker re-consolidated
|
||||
* every tick and burned 0.5 AP3X per pass (116 AP3X in one 24h soak).
|
||||
* The health check also runs BEFORE the low-balance guard now: a wallet that
|
||||
* can already transact must never be told "balance too low to consolidate".
|
||||
*
|
||||
* No lucid / WASM deps so this is unit-testable from a vanilla vitest.
|
||||
*/
|
||||
@@ -26,12 +39,24 @@ export interface PlannedConsolidate {
|
||||
|
||||
export const DEFAULT_COLLATERAL_LOVELACE = 5_000_000n;
|
||||
export const DEFAULT_FEE_RESERVE = 2_000_000n;
|
||||
/** Reshape only when fragmentation passes this; receipt/change UTxOs
|
||||
* accumulating between jobs are fine as long as a candidate exists. */
|
||||
export const DEFAULT_MAX_UTXOS = 6;
|
||||
const MIN_WORKING_LOVELACE = 1_000_000n;
|
||||
|
||||
/** Lovelace of a UTxO iff it carries no native assets; null otherwise. */
|
||||
function pureLovelace(u: UtxoLike): bigint | null {
|
||||
for (const [unit, amount] of Object.entries(u.assets)) {
|
||||
if (unit !== "lovelace" && (amount ?? 0n) > 0n) return null;
|
||||
}
|
||||
return u.assets.lovelace ?? 0n;
|
||||
}
|
||||
|
||||
export function planConsolidate(
|
||||
utxos: UtxoLike[],
|
||||
collateralLovelace: bigint = DEFAULT_COLLATERAL_LOVELACE,
|
||||
feeReserve: bigint = DEFAULT_FEE_RESERVE,
|
||||
maxUtxos: number = DEFAULT_MAX_UTXOS,
|
||||
): PlannedConsolidate {
|
||||
if (utxos.length === 0) {
|
||||
throw new Error("wallet has no UTxOs — fund the address first");
|
||||
@@ -42,6 +67,32 @@ export function planConsolidate(
|
||||
0n,
|
||||
);
|
||||
|
||||
// Structural health check first — reshaping a usable wallet wastes a fee.
|
||||
if (utxos.length <= maxUtxos) {
|
||||
// Smallest qualifying candidate: leave larger UTxOs as working funds.
|
||||
let candidate: bigint | null = null;
|
||||
for (const u of utxos) {
|
||||
const pure = pureLovelace(u);
|
||||
if (pure !== null && pure >= collateralLovelace) {
|
||||
if (candidate === null || pure < candidate) candidate = pure;
|
||||
}
|
||||
}
|
||||
if (candidate !== null) {
|
||||
const workingOutside = totalLovelaceIn - candidate;
|
||||
if (workingOutside >= MIN_WORKING_LOVELACE) {
|
||||
return {
|
||||
inputCount: utxos.length,
|
||||
totalLovelaceIn,
|
||||
collateralOutput: candidate,
|
||||
workingOutput: workingOutside,
|
||||
alreadyHealthy: true,
|
||||
reason: "already-healthy",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reshape needed — only an actual reshape has a funds threshold.
|
||||
const required = collateralLovelace + feeReserve + MIN_WORKING_LOVELACE;
|
||||
if (totalLovelaceIn < required) {
|
||||
throw new Error(
|
||||
@@ -51,23 +102,6 @@ export function planConsolidate(
|
||||
);
|
||||
}
|
||||
|
||||
if (utxos.length === 2) {
|
||||
const lov0 = utxos[0].assets.lovelace ?? 0n;
|
||||
const lov1 = utxos[1].assets.lovelace ?? 0n;
|
||||
const smaller = lov0 < lov1 ? lov0 : lov1;
|
||||
const larger = lov0 < lov1 ? lov1 : lov0;
|
||||
if (smaller >= collateralLovelace) {
|
||||
return {
|
||||
inputCount: 2,
|
||||
totalLovelaceIn,
|
||||
collateralOutput: smaller,
|
||||
workingOutput: larger,
|
||||
alreadyHealthy: true,
|
||||
reason: "already-healthy",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const workingOutput = totalLovelaceIn - collateralLovelace - feeReserve;
|
||||
|
||||
return {
|
||||
|
||||
83
supplier/.env.chandra.example
Normal file
83
supplier/.env.chandra.example
Normal file
@@ -0,0 +1,83 @@
|
||||
# supplier/.env.chandra.example — Chandra OCR-2 supplier (ocr capability)
|
||||
#
|
||||
# Copy to supplier/.env.chandra on the host, chmod 600, fill the blanks.
|
||||
# TESTNET FIRST: run the full lifecycle (advert → escrow → claim → submit →
|
||||
# accept) on testnet before any mainnet value enters this file. Mainnet ops
|
||||
# (funding, advert post, container start) are operator-executed.
|
||||
#
|
||||
# The upstream is any OpenAI-compatible VISION endpoint serving
|
||||
# datalab-to/chandra-ocr-2 (vLLM recommended; `chandra_vllm` docker or
|
||||
# `vllm serve datalab-to/chandra-ocr-2 --revision <sha>`).
|
||||
# LICENSING: the model weights and the hosted API each carry their own
|
||||
# terms. Confirm your own licensing position with the model provider before
|
||||
# serving commercially.
|
||||
|
||||
# ── Identity / chain ──────────────────────────────────────────────────────
|
||||
SUPPLIER_PRIV_KEY_HEX= # gen-keypair output; NEW wallet, working float only
|
||||
NETWORK_ID= # 0 testnet | 1 mainnet — set explicitly, never default
|
||||
OGMIOS_URL= # wss://ogmios.vector.testnet... | mainnet equivalent
|
||||
ADVERT_REF= # <txHash>#<index> from tx:post-advert (below)
|
||||
LIVE_CHAIN= # 1 only when submitting for real
|
||||
VECTOR_ZERO_TIME_MS= # per deploy/README (mainnet 1756485600000)
|
||||
|
||||
# ── Capability ────────────────────────────────────────────────────────────
|
||||
CAPABILITY_KIND=ocr
|
||||
|
||||
# ── Upstream selection ────────────────────────────────────────────────────
|
||||
# "datalab" = Datalab hosted document API (v0 deployment, decided 2026-08-06)
|
||||
# "openai-vision" = self-hosted vLLM (the attestation upgrade path)
|
||||
OCR_UPSTREAM=datalab
|
||||
|
||||
# ── Datalab upstream (OCR_UPSTREAM=datalab) ───────────────────────────────
|
||||
# TERMS: the upstream provider's end-user terms govern how you may use and
|
||||
# redistribute output, and they vary by account and plan. Confirm your own
|
||||
# position with the provider before serving third parties.
|
||||
DATALAB_API_KEY= # provider account key; never commit
|
||||
DATALAB_BASE_URL=https://www.datalab.to
|
||||
DATALAB_MODE=accurate # fast | balanced | accurate
|
||||
DATALAB_TIMEOUT_MS=120000 # submit + poll budget; keep < advert max_processing_ms
|
||||
|
||||
# ── OpenAI-compatible vision upstream (OCR_UPSTREAM=openai-vision) ────────
|
||||
# No /v1 suffix — supplier/src/ocrVision.ts appends /v1/chat/completions.
|
||||
# OPENAI_BASE_URL= # e.g. http://chandra-vllm:8000
|
||||
# OPENAI_API_KEY= # empty for a local vLLM; token for hosted endpoints
|
||||
OPENAI_TIMEOUT_MS=120000 # page inference is seconds; keep < advert max_processing_ms
|
||||
# OPENAI_MAX_TOKENS=8192 # optional output ceiling (advert cap applies regardless)
|
||||
|
||||
# ── OCR extras ────────────────────────────────────────────────────────────
|
||||
# Datalab upstream: the pre-Claim probe defaults to per_job liveness against
|
||||
# {DATALAB_BASE_URL}/api/v1/health - no further config needed. Set
|
||||
# REVISION_PROBE_MODE=off only if you want claims without the health gate.
|
||||
#
|
||||
# openai-vision upstream only (attestation upgrade path):
|
||||
# OCR_PROMPT_OVERRIDE= # exact serving prompt, pinned at integration
|
||||
# HF_MODEL_REVISION= # 40-hex checkpoint SHA; mismatch refuses Claim
|
||||
# REVISION_PROBE_URL= # e.g. http://chandra-vllm:8000/info
|
||||
|
||||
# ── Service ───────────────────────────────────────────────────────────────
|
||||
PORT=8080
|
||||
|
||||
# ── Advert posting (run once per network, testnet first) ──────────────────
|
||||
# Model string convention: datalab upstream = "datalab/convert-accurate"
|
||||
# (honest: hosted engine + mode); self-hosted = "datalab-to/chandra-ocr-2@<sha7>".
|
||||
# Endpoint host: your deployed supplier's public HTTPS endpoint (deploy
|
||||
# target tracked outside this repo, not in this file).
|
||||
#
|
||||
# pnpm --filter @marketplace/supplier tx:post-advert -- \
|
||||
# --ogmios-url $OGMIOS_URL \
|
||||
# --priv-key $SUPPLIER_PRIV_KEY_HEX \
|
||||
# --capability-id ocr.page.extract.chandra-ocr-2.v1 \
|
||||
# --model "datalab/convert-accurate" \
|
||||
# --max-output-tokens 8192 \
|
||||
# --max-processing-ms 150000 \
|
||||
# --price-lovelace 200000 \
|
||||
# --endpoint-url https://<your-supplier-host> \
|
||||
# --detail-uri https://<your-supplier-host>/chandra-detail.json \
|
||||
# --detail-hash <sha256 of the detail JSON>
|
||||
#
|
||||
# max_processing_ms 150000 covers DATALAB_TIMEOUT_MS (120s) + chain legs.
|
||||
# Price 200000 (0.2 AP3X) is the fleet convention placeholder — a future
|
||||
# pricing decision supersedes it. Bonds default to 1 AP3X each.
|
||||
# The detail JSON template lives at supplier/chandra-detail.example.json.
|
||||
# Testnet: DNS is not required — a direct http://<host-ip>:<port> endpoint
|
||||
# works for the testnet advert (retire it after the drills).
|
||||
@@ -27,6 +27,8 @@ RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
|
||||
# Workspace manifests first to maximise layer cache reuse.
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
|
||||
# pnpm-lock.yaml's patchedDependencies hash the patch files at install time.
|
||||
COPY patches ./patches
|
||||
COPY packages/shared/package.json ./packages/shared/package.json
|
||||
COPY supplier/package.json ./supplier/package.json
|
||||
# Buyer + indexer manifests are needed because pnpm-lock.yaml indexes the
|
||||
|
||||
21
supplier/chandra-detail.example.json
Normal file
21
supplier/chandra-detail.example.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"capability_id": "ocr.page.extract.chandra-ocr-2.v1",
|
||||
"weights_source": "hf:datalab-to/chandra-ocr-2",
|
||||
"model_revision": "<40-hex commit SHA; self-hosted (OCR_UPSTREAM=openai-vision) deployments only — the Datalab-hosted default (OCR_UPSTREAM=datalab) is a third-party API with no pinnable checkpoint, so this is unset there>",
|
||||
"license": "modified OpenRAIL-M (weights); Apache-2.0 (code)",
|
||||
"request_shape": {
|
||||
"route": "POST /v1/ocr/extract",
|
||||
"body": { "image_b64": "plain base64, no data: prefix", "mime": "image/png | image/jpeg | image/webp", "output_format": "markdown | html | json" },
|
||||
"one_page_per_job": true
|
||||
},
|
||||
"receipt": {
|
||||
"schema": "v1 (8 fields, Ed25519-signed)",
|
||||
"response_hash": "sha256(canonical({content, output_format}))",
|
||||
"verify": "GET /capability → pub_key_hex; verify signature over canonical(receipt)"
|
||||
},
|
||||
"revision_probe": {
|
||||
"route": "<runtime info route reporting the loaded checkpoint; self-hosted (openai-vision) upstream only>",
|
||||
"policy": "self-hosted (OCR_UPSTREAM=openai-vision) upstream: probe before every Claim, mismatch refuses the job. Datalab-hosted default (OCR_UPSTREAM=datalab): per-job liveness probe only, no checkpoint pin."
|
||||
},
|
||||
"operator_disclosure": "first-party (HAL8-operated supplier)"
|
||||
}
|
||||
@@ -60,8 +60,26 @@ export interface AdvertRef {
|
||||
* "chat" — one-off `llm.text.generate.v1` (single prompt → single completion)
|
||||
* "tts" — `audio.synthesize.piper.v1`
|
||||
* "chat-session" — multi-turn `llm.chat.v1` (escrow bookends, off-chain turns,
|
||||
* streaming). Requires LLM_BACKEND="openai". */
|
||||
export type CapabilityKind = "chat" | "tts" | "chat-session";
|
||||
* streaming). Requires LLM_BACKEND="openai".
|
||||
* "ocr" — model-scoped `ocr.page.extract.<model-slug>.v1` (one page
|
||||
* image → one extraction). Upstream is an OpenAI-compatible
|
||||
* VISION endpoint (OPENAI_BASE_URL), e.g. vLLM serving
|
||||
* datalab-to/chandra-ocr-2. */
|
||||
export type CapabilityKind = "chat" | "tts" | "chat-session" | "ocr";
|
||||
|
||||
/** Served-checkpoint probe cadence for the ocr capability.
|
||||
* "per_job" — probe the upstream before every Claim (default when
|
||||
* REVISION_PROBE_URL is set, and always the default for the
|
||||
* datalab upstream, whose health route is derived).
|
||||
* "off" — no probing. */
|
||||
export type RevisionProbeMode = "per_job" | "off";
|
||||
|
||||
/** Upstream flavor for the ocr capability.
|
||||
* "openai-vision" — OpenAI-compatible vision endpoint (vLLM self-hosting;
|
||||
* supports the revision-pin attestation probe).
|
||||
* "datalab" — Datalab hosted document API (submit + poll; liveness
|
||||
* probe only — hosted supply has no checkpoint to pin). */
|
||||
export type OcrUpstream = "openai-vision" | "datalab";
|
||||
|
||||
/** LLM backend selected for the chat capability. */
|
||||
export type LlmBackend = "ollama" | "openai";
|
||||
@@ -123,6 +141,37 @@ export interface SupplierConfig {
|
||||
maxChatSessions: number;
|
||||
/** Chain settlement mode for chat sessions. Default "full". */
|
||||
chatSettleMode: ChatSettleMode;
|
||||
/**
|
||||
* Upstream flavor for the ocr capability. Env: OCR_UPSTREAM
|
||||
* ("openai-vision" default | "datalab").
|
||||
*/
|
||||
ocrUpstream: OcrUpstream;
|
||||
/** Datalab API key (X-API-Key). Required when ocrUpstream="datalab". */
|
||||
datalabApiKey: string;
|
||||
/** Datalab API origin. Env: DATALAB_BASE_URL, default https://www.datalab.to */
|
||||
datalabBaseUrl: string;
|
||||
/** Datalab quality mode: fast | balanced | accurate. Env: DATALAB_MODE. */
|
||||
datalabMode: string;
|
||||
/** Whole-call budget for submit+poll, ms. Env: DATALAB_TIMEOUT_MS. */
|
||||
datalabTimeoutMs: number;
|
||||
/**
|
||||
* Overrides the built-in per-format OCR instruction prompt when non-empty
|
||||
* (capabilityKind="ocr" + openai-vision upstream only). Env: OCR_PROMPT_OVERRIDE.
|
||||
*/
|
||||
ocrPromptOverride: string;
|
||||
/**
|
||||
* Expected served-checkpoint SHA (40-hex) for the ocr upstream. When set
|
||||
* together with revisionProbeUrl, a probe mismatch refuses the Claim.
|
||||
* Env: HF_MODEL_REVISION. "" = no pin.
|
||||
*/
|
||||
hfModelRevision: string;
|
||||
/**
|
||||
* Serving-runtime info route reporting the loaded checkpoint (TGI-style
|
||||
* /info or vLLM equivalent). Env: REVISION_PROBE_URL. "" = probing off.
|
||||
*/
|
||||
revisionProbeUrl: string;
|
||||
/** Probe cadence; see RevisionProbeMode. Env: REVISION_PROBE_MODE. */
|
||||
revisionProbeMode: RevisionProbeMode;
|
||||
/**
|
||||
* When true, the supplier boots a LiveOgmiosProvider (real submitTx/awaitTx).
|
||||
* Default false → ReadOnlyOgmiosProvider (safe; no chain writes).
|
||||
@@ -174,8 +223,13 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
|
||||
// CAPABILITY_KIND drives whether OLLAMA_URL or PIPER_URL is required.
|
||||
// Default to "chat" so existing chat suppliers keep booting unchanged.
|
||||
const capKindStr = env.CAPABILITY_KIND ?? "chat";
|
||||
if (capKindStr !== "chat" && capKindStr !== "tts" && capKindStr !== "chat-session") {
|
||||
throw new Error('loadConfig: CAPABILITY_KIND must be "chat", "tts", or "chat-session"');
|
||||
if (
|
||||
capKindStr !== "chat" &&
|
||||
capKindStr !== "tts" &&
|
||||
capKindStr !== "chat-session" &&
|
||||
capKindStr !== "ocr"
|
||||
) {
|
||||
throw new Error('loadConfig: CAPABILITY_KIND must be "chat", "tts", "chat-session", or "ocr"');
|
||||
}
|
||||
const capabilityKind: CapabilityKind = capKindStr;
|
||||
|
||||
@@ -204,6 +258,19 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
|
||||
openaiBaseUrl = requireField(env, "OPENAI_BASE_URL");
|
||||
ollamaUrl = env.OLLAMA_URL ?? "";
|
||||
piperUrl = env.PIPER_URL ?? "";
|
||||
} else if (capabilityKind === "ocr") {
|
||||
// Upstream is either an OpenAI-compatible vision endpoint (self-hosted
|
||||
// vLLM; OPENAI_BASE_URL required) or the Datalab hosted API
|
||||
// (DATALAB_API_KEY required, validated below). Ollama and piper are
|
||||
// unused regardless of LLM_BACKEND.
|
||||
const upstream = env.OCR_UPSTREAM ?? "openai-vision";
|
||||
if (upstream === "openai-vision") {
|
||||
openaiBaseUrl = requireField(env, "OPENAI_BASE_URL");
|
||||
} else {
|
||||
openaiBaseUrl = env.OPENAI_BASE_URL ?? "";
|
||||
}
|
||||
ollamaUrl = env.OLLAMA_URL ?? "";
|
||||
piperUrl = env.PIPER_URL ?? "";
|
||||
} else if (capabilityKind === "chat") {
|
||||
if (llmBackend === "openai") {
|
||||
openaiBaseUrl = requireField(env, "OPENAI_BASE_URL");
|
||||
@@ -318,6 +385,52 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
|
||||
}
|
||||
const chatSettleMode: ChatSettleMode = settleModeStr;
|
||||
|
||||
// OCR-capability extras. All optional; harmless when set on other kinds
|
||||
// (ignored by the chat/tts/chat-session paths).
|
||||
const ocrUpstreamStr = env.OCR_UPSTREAM ?? "openai-vision";
|
||||
if (ocrUpstreamStr !== "openai-vision" && ocrUpstreamStr !== "datalab") {
|
||||
throw new Error('loadConfig: OCR_UPSTREAM must be "openai-vision" or "datalab"');
|
||||
}
|
||||
const ocrUpstream: OcrUpstream = ocrUpstreamStr;
|
||||
|
||||
const datalabApiKey = env.DATALAB_API_KEY ?? "";
|
||||
if (capabilityKind === "ocr" && ocrUpstream === "datalab" && datalabApiKey === "") {
|
||||
throw new Error('loadConfig: OCR_UPSTREAM="datalab" requires DATALAB_API_KEY');
|
||||
}
|
||||
const datalabBaseUrl = env.DATALAB_BASE_URL ?? "https://www.datalab.to";
|
||||
const datalabMode = env.DATALAB_MODE ?? "accurate";
|
||||
if (datalabMode !== "fast" && datalabMode !== "balanced" && datalabMode !== "accurate") {
|
||||
throw new Error('loadConfig: DATALAB_MODE must be "fast", "balanced", or "accurate"');
|
||||
}
|
||||
const datalabTimeoutStr = env.DATALAB_TIMEOUT_MS;
|
||||
let datalabTimeoutMs = 120_000;
|
||||
if (datalabTimeoutStr !== undefined && datalabTimeoutStr !== "") {
|
||||
if (!POS_INT_RE.test(datalabTimeoutStr)) {
|
||||
throw new Error("loadConfig: DATALAB_TIMEOUT_MS must be a positive integer");
|
||||
}
|
||||
datalabTimeoutMs = Number(datalabTimeoutStr);
|
||||
}
|
||||
|
||||
const ocrPromptOverride = env.OCR_PROMPT_OVERRIDE ?? "";
|
||||
const hfModelRevision = env.HF_MODEL_REVISION ?? "";
|
||||
if (hfModelRevision !== "" && !/^[0-9a-fA-F]{40}$/.test(hfModelRevision)) {
|
||||
throw new Error("loadConfig: HF_MODEL_REVISION must be a 40-hex commit SHA when set");
|
||||
}
|
||||
const revisionProbeUrl = env.REVISION_PROBE_URL ?? "";
|
||||
// Datalab upstream: the health route is derived from DATALAB_BASE_URL, so
|
||||
// the liveness probe defaults ON without a REVISION_PROBE_URL. The
|
||||
// openai-vision upstream keeps the explicit-URL requirement.
|
||||
const probeDefault =
|
||||
ocrUpstream === "datalab" || revisionProbeUrl !== "" ? "per_job" : "off";
|
||||
const probeModeStr = env.REVISION_PROBE_MODE ?? probeDefault;
|
||||
if (probeModeStr !== "per_job" && probeModeStr !== "off") {
|
||||
throw new Error('loadConfig: REVISION_PROBE_MODE must be "per_job" or "off"');
|
||||
}
|
||||
if (probeModeStr === "per_job" && ocrUpstream === "openai-vision" && revisionProbeUrl === "" && capabilityKind === "ocr") {
|
||||
throw new Error('loadConfig: REVISION_PROBE_MODE="per_job" requires REVISION_PROBE_URL for the openai-vision upstream');
|
||||
}
|
||||
const revisionProbeMode: RevisionProbeMode = probeModeStr;
|
||||
|
||||
const piperTimeoutStr = env.PIPER_TIMEOUT_MS;
|
||||
let piperTimeoutMs = 120_000;
|
||||
if (piperTimeoutStr !== undefined && piperTimeoutStr !== "") {
|
||||
@@ -362,6 +475,15 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
|
||||
chatIdleTimeoutMs,
|
||||
maxChatSessions,
|
||||
chatSettleMode,
|
||||
ocrUpstream,
|
||||
datalabApiKey,
|
||||
datalabBaseUrl,
|
||||
datalabMode,
|
||||
datalabTimeoutMs,
|
||||
ocrPromptOverride,
|
||||
hfModelRevision,
|
||||
revisionProbeUrl,
|
||||
revisionProbeMode,
|
||||
liveChain,
|
||||
walletHealthIntervalMs,
|
||||
};
|
||||
|
||||
249
supplier/src/datalabOcr.ts
Normal file
249
supplier/src/datalabOcr.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* supplier/src/datalabOcr.ts — HTTP client for the Datalab hosted document
|
||||
* API (https://www.datalab.to), used when OCR_UPSTREAM="datalab".
|
||||
*
|
||||
* Contract (verified against documentation.datalab.to, 2026-08-06):
|
||||
* POST {base}/api/v1/convert multipart/form-data, X-API-Key
|
||||
* fields: file (binary), output_format (markdown|html|json), mode
|
||||
* → { success, error, request_id, request_check_url }
|
||||
* GET {base}/api/v1/convert/{id} X-API-Key
|
||||
* → { status: "complete" | ..., markdown|html|json, page_count, error }
|
||||
*
|
||||
* callDatalabOcr() submits ONE page image and polls until complete, then
|
||||
* returns the SAME result shape as ocrVision.callOcrVision so runOcrJob's
|
||||
* receipt construction is shared. Token counts are pinned to 0 — the
|
||||
* Datalab API has no token semantics (mirrors the Piper/TTS convention);
|
||||
* wallclock_ms brackets submit→complete locally.
|
||||
*
|
||||
* JSON output: when output_format="json" the result field can be an object;
|
||||
* it is serialized with JSON.stringify to keep `content` a string. The
|
||||
* receipt commits sha256(canonical({content, output_format})) over exactly
|
||||
* the string the buyer receives, so determinism holds regardless.
|
||||
*
|
||||
* Error reasons (mirror the taxonomy of the other adapters):
|
||||
* "datalab_failure" — non-2xx, network error, success:false, job error
|
||||
* "datalab_timeout" — submit+poll exceeded timeoutMs
|
||||
* "datalab_malformed" — response JSON missing the expected fields
|
||||
*
|
||||
* probeDatalabHealth() — pre-Claim liveness gate for the route (the
|
||||
* revision-probe slot repurposed for hosted supply): GETs {base}/api/v1/health
|
||||
* with the API key; a non-OK answer refuses the Claim so escrows are not
|
||||
* stranded behind a dead upstream.
|
||||
*/
|
||||
|
||||
import type { OcrRequest } from "@marketplace/shared/tx";
|
||||
import type { OcrVisionResult } from "./ocrVision.js";
|
||||
|
||||
export interface CallDatalabOcrParams {
|
||||
/** API origin, no trailing slash. Default deployment: https://www.datalab.to */
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
request: OcrRequest;
|
||||
/** Whole-call budget: submit + polling until complete. */
|
||||
timeoutMs: number;
|
||||
/** Datalab quality mode: fast | balanced | accurate. */
|
||||
mode: string;
|
||||
/** Poll cadence; overridable in tests. Default 2000. */
|
||||
pollIntervalMs?: number;
|
||||
}
|
||||
|
||||
export type DatalabErrorReason = "datalab_failure" | "datalab_timeout" | "datalab_malformed";
|
||||
|
||||
export class DatalabError extends Error {
|
||||
public readonly reason: DatalabErrorReason;
|
||||
constructor(reason: DatalabErrorReason, message?: string) {
|
||||
super(message ?? reason);
|
||||
this.name = "DatalabError";
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
function isAbortError(err: unknown): boolean {
|
||||
if (err instanceof Error) {
|
||||
if (err.name === "AbortError") return true;
|
||||
if ((err as { code?: string }).code === "ABORT_ERR") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const EXT_BY_MIME: Record<string, string> = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/webp": "webp",
|
||||
};
|
||||
|
||||
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = setTimeout(resolve, ms);
|
||||
signal.addEventListener("abort", () => {
|
||||
clearTimeout(t);
|
||||
const err = new Error("aborted");
|
||||
err.name = "AbortError";
|
||||
reject(err);
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export async function callDatalabOcr(params: CallDatalabOcrParams): Promise<OcrVisionResult> {
|
||||
const { baseUrl, apiKey, request, timeoutMs, mode } = params;
|
||||
const pollIntervalMs = params.pollIntervalMs ?? 2_000;
|
||||
const base = baseUrl.replace(/\/+$/, "");
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
// ── 1. Submit ─────────────────────────────────────────────────────
|
||||
const form = new FormData();
|
||||
const bytes = Buffer.from(request.image_b64, "base64");
|
||||
const ext = EXT_BY_MIME[request.mime] ?? "png";
|
||||
form.append("file", new Blob([bytes], { type: request.mime }), `page.${ext}`);
|
||||
form.append("output_format", request.output_format);
|
||||
form.append("mode", mode);
|
||||
|
||||
let submitRes: Response;
|
||||
try {
|
||||
submitRes = await fetch(`${base}/api/v1/convert`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": apiKey },
|
||||
body: form,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) {
|
||||
throw new DatalabError("datalab_timeout", `Datalab submit exceeded ${timeoutMs}ms`);
|
||||
}
|
||||
throw new DatalabError("datalab_failure",
|
||||
`Datalab submit failed: ${(err as Error)?.message ?? String(err)}`);
|
||||
}
|
||||
if (!submitRes.ok) {
|
||||
let detail = "";
|
||||
try { detail = await submitRes.text(); } catch { /* body unavailable */ }
|
||||
throw new DatalabError("datalab_failure",
|
||||
`Datalab submit returned HTTP ${submitRes.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`);
|
||||
}
|
||||
|
||||
let submitBody: Record<string, unknown>;
|
||||
try {
|
||||
submitBody = await submitRes.json() as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new DatalabError("datalab_malformed", "Datalab submit response was not JSON");
|
||||
}
|
||||
if (submitBody.success !== true) {
|
||||
throw new DatalabError("datalab_failure",
|
||||
`Datalab submit success=false: ${String(submitBody.error ?? "no error detail")}`);
|
||||
}
|
||||
const requestId = submitBody.request_id;
|
||||
if (typeof requestId !== "string" || requestId.length === 0) {
|
||||
throw new DatalabError("datalab_malformed", "Datalab submit response missing request_id");
|
||||
}
|
||||
|
||||
// ── 2. Poll until complete ────────────────────────────────────────
|
||||
const checkUrl = typeof submitBody.request_check_url === "string" && submitBody.request_check_url.length > 0
|
||||
? submitBody.request_check_url
|
||||
: `${base}/api/v1/convert/${requestId}`;
|
||||
|
||||
for (;;) {
|
||||
let pollRes: Response;
|
||||
try {
|
||||
pollRes = await fetch(checkUrl, {
|
||||
headers: { "X-API-Key": apiKey },
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) {
|
||||
throw new DatalabError("datalab_timeout", `Datalab poll exceeded ${timeoutMs}ms budget`);
|
||||
}
|
||||
throw new DatalabError("datalab_failure",
|
||||
`Datalab poll failed: ${(err as Error)?.message ?? String(err)}`);
|
||||
}
|
||||
if (!pollRes.ok) {
|
||||
throw new DatalabError("datalab_failure", `Datalab poll returned HTTP ${pollRes.status}`);
|
||||
}
|
||||
let poll: Record<string, unknown>;
|
||||
try {
|
||||
poll = await pollRes.json() as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new DatalabError("datalab_malformed", "Datalab poll response was not JSON");
|
||||
}
|
||||
|
||||
const status = typeof poll.status === "string" ? poll.status.toLowerCase() : "";
|
||||
if (status === "complete") {
|
||||
if (poll.error && typeof poll.error === "string" && poll.error.length > 0) {
|
||||
throw new DatalabError("datalab_failure", `Datalab conversion error: ${poll.error}`);
|
||||
}
|
||||
const raw = poll[request.output_format];
|
||||
let content: string;
|
||||
if (typeof raw === "string") {
|
||||
content = raw;
|
||||
} else if (raw !== null && raw !== undefined && typeof raw === "object") {
|
||||
content = JSON.stringify(raw);
|
||||
} else {
|
||||
throw new DatalabError("datalab_malformed",
|
||||
`Datalab result missing "${request.output_format}" field`);
|
||||
}
|
||||
if (content.length === 0) {
|
||||
throw new DatalabError("datalab_malformed", "Datalab result content is empty");
|
||||
}
|
||||
return {
|
||||
content,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
wallclock_ms: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
if (status === "failed" || status === "error") {
|
||||
throw new DatalabError("datalab_failure",
|
||||
`Datalab conversion status=${status}: ${String(poll.error ?? "no detail")}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await sleep(pollIntervalMs, controller.signal);
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) {
|
||||
throw new DatalabError("datalab_timeout", `Datalab poll exceeded ${timeoutMs}ms budget`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Liveness probe (pre-Claim gate for hosted supply) ─────────────────────
|
||||
|
||||
export interface ProbeDatalabParams {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface ProbeDatalabResult {
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export async function probeDatalabHealth(params: ProbeDatalabParams): Promise<ProbeDatalabResult> {
|
||||
const base = params.baseUrl.replace(/\/+$/, "");
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), params.timeoutMs);
|
||||
try {
|
||||
const res = await fetch(`${base}/api/v1/health`, {
|
||||
headers: { "X-API-Key": params.apiKey },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { ok: false, detail: `health HTTP ${res.status}` };
|
||||
}
|
||||
return { ok: true, detail: "health ok" };
|
||||
} catch (err) {
|
||||
const msg = isAbortError(err)
|
||||
? `health probe exceeded ${params.timeoutMs}ms`
|
||||
: `health probe failed: ${(err as Error)?.message ?? String(err)}`;
|
||||
return { ok: false, detail: msg };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -38,11 +38,15 @@ import type { SupplierConfig } from "./config.js";
|
||||
import * as ollama from "./ollama.js";
|
||||
import * as openai from "./openai.js";
|
||||
import * as piper from "./piper.js";
|
||||
import * as ocrVision from "./ocrVision.js";
|
||||
import * as datalabOcr from "./datalabOcr.js";
|
||||
import type { OcrRequest } from "@marketplace/shared/tx";
|
||||
import type {
|
||||
JobStore,
|
||||
JobResponsePayload,
|
||||
ChatJobResponsePayload,
|
||||
TtsJobResponsePayload,
|
||||
OcrJobResponsePayload,
|
||||
} from "./jobs.js";
|
||||
|
||||
export interface RunChatJobDeps {
|
||||
@@ -364,3 +368,173 @@ export async function runTtsJob(params: RunTtsJobParams): Promise<void> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── runOcrJob ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Mirror of runTtsJob for model-scoped `ocr.page.extract.<model-slug>.v1`
|
||||
// capabilities. Same 7-step shape (setRunning → upstream → receipt →
|
||||
// buildSubmit → awaitTx → complete → finally release). Differences from the
|
||||
// TTS path:
|
||||
//
|
||||
// - Upstream is `ocrVision.callOcrVision` against an OpenAI-compatible
|
||||
// VISION endpoint (vLLM serving e.g. datalab-to/chandra-ocr-2); the
|
||||
// one-page image rides as a data-URL content part.
|
||||
// - response_hash = sha256(canonical({content, output_format})) — commits
|
||||
// the extraction text AND the shape it was requested in.
|
||||
// - prompt_tokens / completion_tokens come from upstream usage (vision
|
||||
// tokens are real tokens, unlike Piper).
|
||||
|
||||
export interface RunOcrJobParams {
|
||||
deps: RunChatJobDeps;
|
||||
jobId: string;
|
||||
escrowRef: string;
|
||||
claimedRef: OutputReference;
|
||||
advert: AdvertDatum;
|
||||
escrowDatum: EscrowDatum;
|
||||
/** The OCR request envelope. The PROMPT_HASH committed by the buyer in
|
||||
* the escrow datum is sha256(canonicalize(this same object)), validated
|
||||
* by the route before Claim. */
|
||||
requestBody: OcrRequest;
|
||||
}
|
||||
|
||||
export async function runOcrJob(params: RunOcrJobParams): Promise<void> {
|
||||
const { deps, jobId, escrowRef, claimedRef, advert, escrowDatum, requestBody } = params;
|
||||
|
||||
deps.jobs.setRunning(jobId);
|
||||
|
||||
try {
|
||||
// ── 2. Call the OCR upstream (openai-vision or datalab) ───────────
|
||||
let inference: ocrVision.OcrVisionResult;
|
||||
try {
|
||||
if (deps.config.ocrUpstream === "datalab") {
|
||||
inference = await datalabOcr.callDatalabOcr({
|
||||
baseUrl: deps.config.datalabBaseUrl,
|
||||
apiKey: deps.config.datalabApiKey,
|
||||
request: requestBody,
|
||||
timeoutMs: deps.config.datalabTimeoutMs,
|
||||
mode: deps.config.datalabMode,
|
||||
});
|
||||
} else {
|
||||
const configuredMax = deps.config.openaiMaxTokens;
|
||||
const maxTokens =
|
||||
configuredMax > 0
|
||||
? Math.min(configuredMax, advert.max_output_tokens)
|
||||
: advert.max_output_tokens;
|
||||
inference = await ocrVision.callOcrVision({
|
||||
baseUrl: deps.config.openaiBaseUrl,
|
||||
model: advert.model,
|
||||
request: requestBody,
|
||||
timeoutMs: deps.config.openaiTimeoutMs,
|
||||
apiKey: deps.config.openaiApiKey,
|
||||
maxTokens,
|
||||
promptOverride: deps.config.ocrPromptOverride,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const rawReason =
|
||||
(err as ocrVision.OcrVisionError | datalabOcr.DatalabError)?.reason ??
|
||||
(deps.config.ocrUpstream === "datalab" ? "datalab_failure" : "ocr_failure");
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// *_timeout collapses to the failure reason for jobs.fail, matching
|
||||
// the chat/tts runner failure-code table (502 only).
|
||||
const collapsedReason =
|
||||
rawReason === "ocr_timeout" ? "ocr_failure"
|
||||
: rawReason === "datalab_timeout" ? "datalab_failure"
|
||||
: rawReason;
|
||||
console.warn(
|
||||
`[job_failed] jobId=${jobId} reason=${rawReason} httpStatus=502 msg=${message}`,
|
||||
);
|
||||
deps.jobs.fail(jobId, {
|
||||
httpStatus: 502,
|
||||
reason: collapsedReason,
|
||||
message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 3. Build + sign receipt ───────────────────────────────────────
|
||||
const responseHash = sha256Hex(canonicalize({
|
||||
content: inference.content,
|
||||
output_format: requestBody.output_format,
|
||||
}));
|
||||
|
||||
const receipt = buildReceipt({
|
||||
prompt_hash: escrowDatum.prompt_hash,
|
||||
response_hash: responseHash,
|
||||
model: advert.model,
|
||||
prompt_tokens: inference.prompt_tokens,
|
||||
completion_tokens: inference.completion_tokens,
|
||||
wallclock_ms: inference.wallclock_ms,
|
||||
supplier_pkh: deps.supplierKey.pubKeyHash,
|
||||
escrow_ref: escrowRef,
|
||||
});
|
||||
const signed = signReceipt(receipt, deps.supplierKey.privateKeyHex);
|
||||
const resultHash = receiptResultHash(signed);
|
||||
|
||||
// ── 4+5. Submit tx: build + confirm as ONE wallet critical section ──
|
||||
const submitOutcome = await deps.state.walletMutex.run(async () => {
|
||||
let built: { txCborHex: string; expectedTxHash: string };
|
||||
try {
|
||||
built = await buildSubmitTx({
|
||||
chain: deps.chain,
|
||||
supplierKey: deps.supplierKey,
|
||||
escrowRef: claimedRef,
|
||||
receiptHash: resultHash,
|
||||
});
|
||||
} catch (err) {
|
||||
return { kind: "build_failed" as const, err };
|
||||
}
|
||||
try {
|
||||
await deps.chain.awaitTx(built.expectedTxHash, 60_000);
|
||||
} catch (err) {
|
||||
return { kind: "await_failed" as const, err };
|
||||
}
|
||||
return { kind: "ok" as const };
|
||||
});
|
||||
if (submitOutcome.kind === "build_failed") {
|
||||
const message = submitOutcome.err instanceof Error ? submitOutcome.err.message : String(submitOutcome.err);
|
||||
console.warn(
|
||||
`[job_failed] jobId=${jobId} reason=submit_failed httpStatus=502 msg=${message}`,
|
||||
);
|
||||
deps.jobs.fail(jobId, {
|
||||
httpStatus: 502,
|
||||
reason: "submit_failed",
|
||||
message: `Submit tx failed: ${message}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (submitOutcome.kind === "await_failed") {
|
||||
const msg = submitOutcome.err instanceof Error ? submitOutcome.err.message : String(submitOutcome.err);
|
||||
console.warn(
|
||||
`[job_failed] jobId=${jobId} reason=submit_timeout httpStatus=502 msg=${msg}`,
|
||||
);
|
||||
deps.jobs.fail(jobId, {
|
||||
httpStatus: 502,
|
||||
reason: "submit_timeout",
|
||||
message: `Submit awaitTx failed: ${msg}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 6. Mark complete ──────────────────────────────────────────────
|
||||
const payload: OcrJobResponsePayload = {
|
||||
kind: "ocr",
|
||||
output_format: requestBody.output_format,
|
||||
content: inference.content,
|
||||
usage: {
|
||||
prompt_tokens: inference.prompt_tokens,
|
||||
completion_tokens: inference.completion_tokens,
|
||||
total_tokens: inference.prompt_tokens + inference.completion_tokens,
|
||||
},
|
||||
receipt: signed.receipt as unknown as Record<string, unknown>,
|
||||
receipt_signature: signed.signature,
|
||||
};
|
||||
deps.jobs.complete(jobId, payload);
|
||||
} finally {
|
||||
try {
|
||||
deps.state.release(escrowRef);
|
||||
} catch {
|
||||
/* never let lock-release escape */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,13 +63,35 @@ export interface TtsJobResponsePayload {
|
||||
receipt_signature: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OCR-flavoured terminal payload. Stored when a `runOcrJob` succeeds.
|
||||
* `content` is the extraction in the requested output_format (markdown/
|
||||
* html/json as text). The receipt's response_hash commits
|
||||
* sha256(canonical({content, output_format})).
|
||||
*/
|
||||
export interface OcrJobResponsePayload {
|
||||
kind: "ocr";
|
||||
output_format: string; // "markdown" | "html" | "json"
|
||||
content: string;
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
receipt: Record<string, unknown>;
|
||||
receipt_signature: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated union of every job-payload shape the store can hold.
|
||||
* `kind` defaults to "chat" on the chat shape (older code paths don't
|
||||
* set it; new `runChatJob` writes can set kind: "chat" explicitly to make
|
||||
* the discriminator narrowing reliable).
|
||||
*/
|
||||
export type JobResponsePayload = ChatJobResponsePayload | TtsJobResponsePayload;
|
||||
export type JobResponsePayload =
|
||||
| ChatJobResponsePayload
|
||||
| TtsJobResponsePayload
|
||||
| OcrJobResponsePayload;
|
||||
|
||||
export interface JobFailure {
|
||||
httpStatus: number;
|
||||
|
||||
271
supplier/src/ocrVision.ts
Normal file
271
supplier/src/ocrVision.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* supplier/src/ocrVision.ts — HTTP client for an OpenAI-compatible VISION
|
||||
* /v1/chat/completions endpoint (vLLM serving an OCR VLM, e.g.
|
||||
* datalab-to/chandra-ocr-2). Used by the `ocr` capability kind.
|
||||
*
|
||||
* callOcrVision({ baseUrl, model, request, timeoutMs, ... })
|
||||
* POST ${baseUrl}/v1/chat/completions with a single user message whose
|
||||
* content is OpenAI vision content parts:
|
||||
* [{type:"image_url", image_url:{url:"data:<mime>;base64,<b64>"}},
|
||||
* {type:"text", text:<instruction for the output format>}]
|
||||
* Returns { content, prompt_tokens, completion_tokens, wallclock_ms }
|
||||
* — the same shape as ollama.ts / openai.ts so receipt construction is
|
||||
* identical downstream.
|
||||
*
|
||||
* The instruction text per output_format is a serving-side implementation
|
||||
* detail (like temperature); the buyer's on-chain commitment covers the
|
||||
* OcrRequest envelope, not the serving prompt. The exact Chandra prompt
|
||||
* convention gets pinned against a live endpoint during integration —
|
||||
* override per deployment via OCR_PROMPT_OVERRIDE without a code change.
|
||||
*
|
||||
* Error reasons:
|
||||
* "ocr_failure" — non-2xx HTTP, network error, unexpected fetch failure
|
||||
* "ocr_timeout" — request exceeded timeoutMs
|
||||
* "ocr_malformed" — response missing choices[0].message.content (or empty)
|
||||
*
|
||||
* probeRevision() — best-effort served-checkpoint probe (light v0). GETs a
|
||||
* runtime info route and extracts a revision/SHA field if one is present.
|
||||
* The route handler refuses to Claim on a mismatch when a pin is
|
||||
* configured. When the receipt schema is extended to carry it, the probed
|
||||
* value feeds `model_revision`; until then it is a claim gate and
|
||||
* a log line.
|
||||
*/
|
||||
|
||||
import type { OcrRequest } from "@marketplace/shared/tx";
|
||||
|
||||
export interface CallOcrVisionParams {
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
request: OcrRequest;
|
||||
timeoutMs: number;
|
||||
/** Optional bearer token (hosted endpoints). "" sends no header. */
|
||||
apiKey?: string;
|
||||
/** Hard output-token ceiling forwarded as max_tokens when > 0. */
|
||||
maxTokens?: number;
|
||||
/** Overrides the built-in per-format instruction when non-empty. */
|
||||
promptOverride?: string;
|
||||
}
|
||||
|
||||
export interface OcrVisionResult {
|
||||
content: string;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
wallclock_ms: number;
|
||||
}
|
||||
|
||||
export type OcrVisionErrorReason = "ocr_failure" | "ocr_timeout" | "ocr_malformed";
|
||||
|
||||
export class OcrVisionError extends Error {
|
||||
public readonly reason: OcrVisionErrorReason;
|
||||
constructor(reason: OcrVisionErrorReason, message?: string) {
|
||||
super(message ?? reason);
|
||||
this.name = "OcrVisionError";
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
function isAbortError(err: unknown): boolean {
|
||||
if (err instanceof Error) {
|
||||
if (err.name === "AbortError") return true;
|
||||
if ((err as { code?: string }).code === "ABORT_ERR") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Default extraction instructions per output_format. Placeholder wording
|
||||
* pending the Chandra-convention pin (integration item); serving deployments
|
||||
* override via OCR_PROMPT_OVERRIDE. */
|
||||
export function defaultOcrInstruction(outputFormat: string): string {
|
||||
switch (outputFormat) {
|
||||
case "html":
|
||||
return "Convert this document page to clean, semantic HTML. Preserve reading order, tables, form fields and layout structure. Output only the HTML.";
|
||||
case "json":
|
||||
return "Extract this document page as JSON with layout blocks (type, bbox, text) preserving reading order, tables and form fields. Output only the JSON.";
|
||||
case "markdown":
|
||||
default:
|
||||
return "Convert this document page to clean markdown. Preserve reading order, tables and structure. Transcribe handwriting faithfully. Output only the markdown.";
|
||||
}
|
||||
}
|
||||
|
||||
export async function callOcrVision(params: CallOcrVisionParams): Promise<OcrVisionResult> {
|
||||
const { baseUrl, model, request, timeoutMs, apiKey, maxTokens, promptOverride } = params;
|
||||
const url = `${baseUrl.replace(/\/+$/, "")}/v1/chat/completions`;
|
||||
|
||||
const instruction =
|
||||
promptOverride && promptOverride.length > 0
|
||||
? promptOverride
|
||||
: defaultOcrInstruction(request.output_format);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: { url: `data:${request.mime};base64,${request.image_b64}` },
|
||||
},
|
||||
{ type: "text", text: instruction },
|
||||
],
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
};
|
||||
if (typeof maxTokens === "number" && maxTokens > 0) payload.max_tokens = maxTokens;
|
||||
const body = JSON.stringify(payload);
|
||||
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
if (apiKey) {
|
||||
headers.authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
const startedAt = Date.now();
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) {
|
||||
throw new OcrVisionError("ocr_timeout", `OCR request exceeded ${timeoutMs}ms`);
|
||||
}
|
||||
throw new OcrVisionError(
|
||||
"ocr_failure",
|
||||
`OCR fetch failed: ${(err as Error)?.message ?? String(err)}`,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
detail = await response.text();
|
||||
} catch {
|
||||
// ignore — body unavailable
|
||||
}
|
||||
throw new OcrVisionError(
|
||||
"ocr_failure",
|
||||
`OCR upstream returned HTTP ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = await response.json();
|
||||
} catch (err) {
|
||||
throw new OcrVisionError(
|
||||
"ocr_malformed",
|
||||
`OCR response was not valid JSON: ${(err as Error)?.message ?? String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new OcrVisionError("ocr_malformed", "OCR response was not an object");
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const choicesRaw = obj.choices;
|
||||
if (!Array.isArray(choicesRaw) || choicesRaw.length === 0) {
|
||||
throw new OcrVisionError("ocr_malformed", "OCR response missing 'choices' array");
|
||||
}
|
||||
const firstChoice = choicesRaw[0];
|
||||
if (!firstChoice || typeof firstChoice !== "object") {
|
||||
throw new OcrVisionError("ocr_malformed", "OCR response choices[0] not an object");
|
||||
}
|
||||
const messageRaw = (firstChoice as Record<string, unknown>).message;
|
||||
if (!messageRaw || typeof messageRaw !== "object") {
|
||||
throw new OcrVisionError("ocr_malformed", "OCR response missing 'choices[0].message'");
|
||||
}
|
||||
const content = (messageRaw as Record<string, unknown>).content;
|
||||
if (typeof content !== "string" || content.length === 0) {
|
||||
throw new OcrVisionError(
|
||||
"ocr_malformed",
|
||||
"OCR response missing/empty choices[0].message.content",
|
||||
);
|
||||
}
|
||||
|
||||
const usageRaw = obj.usage;
|
||||
let promptTokens = 0;
|
||||
let completionTokens = 0;
|
||||
if (usageRaw && typeof usageRaw === "object") {
|
||||
const usage = usageRaw as Record<string, unknown>;
|
||||
if (typeof usage.prompt_tokens === "number") promptTokens = usage.prompt_tokens;
|
||||
if (typeof usage.completion_tokens === "number") completionTokens = usage.completion_tokens;
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: completionTokens,
|
||||
wallclock_ms: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Revision probe (light v0) ──────────────────────────────────────────────
|
||||
|
||||
export interface ProbeRevisionParams {
|
||||
probeUrl: string;
|
||||
timeoutMs: number;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
export interface ProbeRevisionResult {
|
||||
ok: boolean;
|
||||
/** 40-hex checkpoint SHA when the runtime reports one; "" otherwise. */
|
||||
servedSha: string;
|
||||
/** Human-readable detail for logs/alerts. */
|
||||
detail: string;
|
||||
}
|
||||
|
||||
const SHA40_RE = /^[0-9a-f]{40}$/i;
|
||||
|
||||
/** Fields observed across serving runtimes that carry a checkpoint id
|
||||
* (TGI /info: model_sha; vLLM variants: sha / revision / model_revision). */
|
||||
const SHA_FIELDS = ["model_sha", "sha", "revision", "model_revision"];
|
||||
|
||||
export async function probeRevision(params: ProbeRevisionParams): Promise<ProbeRevisionResult> {
|
||||
const { probeUrl, timeoutMs, apiKey } = params;
|
||||
const headers: Record<string, string> = {};
|
||||
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(probeUrl, { headers, signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
return { ok: false, servedSha: "", detail: `probe HTTP ${response.status}` };
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = await response.json();
|
||||
} catch {
|
||||
return { ok: false, servedSha: "", detail: "probe response not JSON" };
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return { ok: false, servedSha: "", detail: "probe response not an object" };
|
||||
}
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
for (const field of SHA_FIELDS) {
|
||||
const v = obj[field];
|
||||
if (typeof v === "string" && SHA40_RE.test(v)) {
|
||||
return { ok: true, servedSha: v.toLowerCase(), detail: `probe ${field}=${v}` };
|
||||
}
|
||||
}
|
||||
return { ok: true, servedSha: "", detail: "probe answered without a revision field" };
|
||||
} catch (err) {
|
||||
const msg = isAbortError(err)
|
||||
? `probe exceeded ${timeoutMs}ms`
|
||||
: `probe failed: ${(err as Error)?.message ?? String(err)}`;
|
||||
return { ok: false, servedSha: "", detail: msg };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -44,12 +44,19 @@ import {
|
||||
mockSlotToWallclockMs,
|
||||
detectCborBackend,
|
||||
normalizeChatMessage,
|
||||
ocrPromptHash,
|
||||
ALLOWED_OCR_MIMES,
|
||||
ALLOWED_OCR_OUTPUT_FORMATS,
|
||||
MAX_OCR_IMAGE_B64_CHARS,
|
||||
} from "@marketplace/shared/tx";
|
||||
import type { OcrRequest } from "@marketplace/shared/tx";
|
||||
|
||||
import type { SupplierState } from "./state.js";
|
||||
import type { SupplierConfig } from "./config.js";
|
||||
import { JobStore } from "./jobs.js";
|
||||
import { runChatJob, runTtsJob } from "./jobRunner.js";
|
||||
import { runChatJob, runTtsJob, runOcrJob } from "./jobRunner.js";
|
||||
import { probeRevision } from "./ocrVision.js";
|
||||
import { probeDatalabHealth } from "./datalabOcr.js";
|
||||
import { ChatSessionStore, type ChatSessionRecord } from "./chatSession.js";
|
||||
import { endChatSession, type EndChatSessionDeps } from "./chatSessionRunner.js";
|
||||
import { callOpenAiStream } from "./openai.js";
|
||||
@@ -427,9 +434,9 @@ function makeGetJobHandler(deps: ResolvedDeps) {
|
||||
// Defensive narrowing: this poll route only knows how to render the
|
||||
// chat shape. A future shared JobStore could mix capabilities; we'd
|
||||
// want each kind's poll to be sure it's reading its own.
|
||||
if ("audio_b64" in payload) {
|
||||
if (payload.kind === "ocr" || "audio_b64" in payload) {
|
||||
return jsonError(res, 500, "wrong_payload_kind",
|
||||
"tts payload returned to chat poll route");
|
||||
"non-chat payload returned to chat poll route");
|
||||
}
|
||||
return res.status(200).json({
|
||||
choices: payload.choices,
|
||||
@@ -696,6 +703,303 @@ function makeGetTtsJobHandler(deps: ResolvedDeps) {
|
||||
};
|
||||
}
|
||||
|
||||
// ─── POST /v1/ocr/extract ───────────────────────────────────────────────────
|
||||
//
|
||||
// Mirror of makeTtsHandler for model-scoped `ocr.page.extract.<model-slug>.v1`
|
||||
// capabilities. Same lifecycle (validate → resolve advert → resolve escrow →
|
||||
// hash check → claim → fire-and-forget runner → 202). Differences:
|
||||
// - body shape: { image_b64, mime, output_format } (one page per job)
|
||||
// - prompt_hash is ocrPromptHash(envelope) — shared with the buyer-side
|
||||
// builder in packages/shared/src/tx/escrow/postOcrEscrow.ts
|
||||
// - optional served-revision probe BEFORE Claim: a mispinned or
|
||||
// unreachable upstream refuses the job instead of stranding the escrow
|
||||
// in Claimed.
|
||||
|
||||
/** Plain base64 (standard alphabet, optional padding). Shared shape with the
|
||||
* buyer-side validator; kept local so route validation stays dependency-light. */
|
||||
const OCR_BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||
|
||||
function makeOcrHandler(deps: ResolvedDeps) {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
// Slot held by THIS request (null until acquired / after hand-off).
|
||||
let acquiredRef: string | null = null;
|
||||
try {
|
||||
// ── 1. Header ───────────────────────────────────────────────────
|
||||
const headerVal = req.header("X-Escrow-Ref");
|
||||
if (!headerVal) {
|
||||
return jsonError(res, 400, "escrow_ref_required", "X-Escrow-Ref header is required");
|
||||
}
|
||||
const escrowRef = parseEscrowRef(headerVal);
|
||||
if (escrowRef === null) {
|
||||
return jsonError(res, 400, "escrow_ref_malformed",
|
||||
'X-Escrow-Ref must match "<64-hex>#<int>"');
|
||||
}
|
||||
const escrowRefStr = `${escrowRef.txHash}#${escrowRef.index}`;
|
||||
|
||||
// ── 2. Body ─────────────────────────────────────────────────────
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const imageB64 = typeof body.image_b64 === "string" ? body.image_b64 : "";
|
||||
if (imageB64.length === 0) {
|
||||
return jsonError(res, 400, "image_required",
|
||||
"body.image_b64 must be a non-empty base64 string");
|
||||
}
|
||||
if (imageB64.length > MAX_OCR_IMAGE_B64_CHARS) {
|
||||
return jsonError(res, 413, "image_too_large",
|
||||
`body.image_b64 exceeds ${MAX_OCR_IMAGE_B64_CHARS} chars`);
|
||||
}
|
||||
if (!OCR_BASE64_RE.test(imageB64)) {
|
||||
return jsonError(res, 400, "image_not_base64",
|
||||
"body.image_b64 must be plain base64 (no data: prefix, no whitespace)");
|
||||
}
|
||||
const mime = typeof body.mime === "string" ? body.mime : "";
|
||||
if (!ALLOWED_OCR_MIMES.has(mime)) {
|
||||
return jsonError(res, 400, "mime_invalid",
|
||||
`mime must be one of: ${[...ALLOWED_OCR_MIMES].join(", ")}`);
|
||||
}
|
||||
const outputFormat = typeof body.output_format === "string" ? body.output_format : "";
|
||||
if (!ALLOWED_OCR_OUTPUT_FORMATS.has(outputFormat)) {
|
||||
return jsonError(res, 400, "output_format_invalid",
|
||||
`output_format must be one of: ${[...ALLOWED_OCR_OUTPUT_FORMATS].join(", ")}`);
|
||||
}
|
||||
const ocrRequest: OcrRequest = { image_b64: imageB64, mime, output_format: outputFormat };
|
||||
|
||||
// ── 3. Advert ───────────────────────────────────────────────────
|
||||
const advertResult = await fetchActiveAdvert(deps);
|
||||
if ("error" in advertResult) {
|
||||
const e = advertResult.error;
|
||||
return jsonError(res, e.status, e.reason, e.message);
|
||||
}
|
||||
const advert = advertResult.datum;
|
||||
|
||||
// ── 4. Escrow ───────────────────────────────────────────────────
|
||||
const escrowUtxo = await deps.chain.queryUtxo(escrowRef);
|
||||
if (escrowUtxo === null || !escrowUtxo.datumHex) {
|
||||
return jsonError(res, 404, "escrow_not_found",
|
||||
`escrow UTxO ${escrowRefStr} not found on chain`);
|
||||
}
|
||||
let escrowDatum: EscrowDatum;
|
||||
try {
|
||||
escrowDatum = decodeEscrowDatum(escrowUtxo.datumHex);
|
||||
} catch (err) {
|
||||
return jsonError(res, 404, "escrow_decode_failed", (err as Error).message);
|
||||
}
|
||||
|
||||
// ── 5. State / identity / capability ────────────────────────────
|
||||
if (escrowDatum.state !== "Open") {
|
||||
return jsonError(res, 409, "escrow_not_claimable",
|
||||
`escrow state is ${escrowDatum.state}, expected Open`);
|
||||
}
|
||||
if (escrowDatum.supplier_pkh !== deps.supplierKey.pubKeyHash) {
|
||||
return jsonError(res, 403, "wrong_supplier",
|
||||
"escrow supplier_pkh does not match this node");
|
||||
}
|
||||
if (escrowDatum.capability_id !== advert.capability_id) {
|
||||
return jsonError(res, 409, "capability_mismatch",
|
||||
`escrow capability ${escrowDatum.capability_id} != advert ${advert.capability_id}`);
|
||||
}
|
||||
|
||||
// ── 6. Hash checks ──────────────────────────────────────────────
|
||||
const expectedRequestSpecHash = sha256Hex(canonicalize({
|
||||
capability_id: advert.capability_id,
|
||||
max_output_tokens: advert.max_output_tokens,
|
||||
model: advert.model,
|
||||
}));
|
||||
if (escrowDatum.request_spec_hash !== expectedRequestSpecHash) {
|
||||
return jsonError(res, 409, "request_spec_mismatch",
|
||||
"request_spec_hash in escrow does not match advert spec");
|
||||
}
|
||||
// The OCR prompt commitment is the canonicalisation of the full
|
||||
// request envelope. The buyer SDK computes the same shape (shared
|
||||
// ocrPromptHash) when building the escrow datum.
|
||||
const expectedPromptHash = ocrPromptHash(ocrRequest);
|
||||
if (escrowDatum.prompt_hash !== expectedPromptHash) {
|
||||
return jsonError(res, 409, "prompt_mismatch",
|
||||
"prompt_hash in escrow does not match request body");
|
||||
}
|
||||
|
||||
// ── 7. Deadline ─────────────────────────────────────────────────
|
||||
const tipSlot = await deps.chain.tip();
|
||||
const isLive = detectCborBackend(deps.chain) === "live";
|
||||
const nowMs = isLive
|
||||
? Date.now()
|
||||
: Math.max(mockSlotToWallclockMs(tipSlot), escrowDatum.posted_at);
|
||||
if (nowMs >= escrowDatum.deliver_by) {
|
||||
return jsonError(res, 408, "past_deliver_by",
|
||||
`now ${nowMs} >= deliver_by ${escrowDatum.deliver_by}`);
|
||||
}
|
||||
|
||||
// ── 7.5. Upstream probe (before Claim, when configured) ─────────
|
||||
// A failure REFUSES the job here — claiming with a dead or mispinned
|
||||
// upstream would strand the escrow (and, for self-hosted supply,
|
||||
// corrupt the attestation story). 503 tells the buyer to retry/route
|
||||
// elsewhere. Datalab upstream: liveness only (hosted supply has no
|
||||
// checkpoint to pin). openai-vision upstream: liveness + optional
|
||||
// served-revision match against HF_MODEL_REVISION.
|
||||
if (deps.config.revisionProbeMode === "per_job") {
|
||||
if (deps.config.ocrUpstream === "datalab") {
|
||||
const probe = await probeDatalabHealth({
|
||||
baseUrl: deps.config.datalabBaseUrl,
|
||||
apiKey: deps.config.datalabApiKey,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
if (!probe.ok) {
|
||||
console.warn(`[upstream_probe_failed] ${probe.detail}`);
|
||||
return jsonError(res, 503, "upstream_probe_failed",
|
||||
`upstream health probe failed: ${probe.detail}`);
|
||||
}
|
||||
} else if (deps.config.revisionProbeUrl !== "") {
|
||||
const probe = await probeRevision({
|
||||
probeUrl: deps.config.revisionProbeUrl,
|
||||
timeoutMs: 10_000,
|
||||
apiKey: deps.config.openaiApiKey,
|
||||
});
|
||||
if (!probe.ok) {
|
||||
console.warn(`[revision_probe_failed] ${probe.detail}`);
|
||||
return jsonError(res, 503, "revision_probe_failed",
|
||||
`served-revision probe failed: ${probe.detail}`);
|
||||
}
|
||||
if (
|
||||
deps.config.hfModelRevision !== "" &&
|
||||
probe.servedSha !== "" &&
|
||||
probe.servedSha !== deps.config.hfModelRevision.toLowerCase()
|
||||
) {
|
||||
console.warn(
|
||||
`[revision_mismatch] expected=${deps.config.hfModelRevision} served=${probe.servedSha}`,
|
||||
);
|
||||
return jsonError(res, 503, "revision_mismatch",
|
||||
"served checkpoint does not match the pinned revision");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 8. Acquire session slot ─────────────────────────────────────
|
||||
if (!deps.state.tryAcquire(escrowRefStr)) {
|
||||
return jsonError(res, 409, "supplier_busy", "supplier is already working another job");
|
||||
}
|
||||
acquiredRef = escrowRefStr;
|
||||
|
||||
// ── 9. Claim tx: build + confirm as ONE wallet critical section ──
|
||||
const claimOutcome = await deps.state.walletMutex.run(async () => {
|
||||
let built;
|
||||
try {
|
||||
built = await buildClaimTx({
|
||||
chain: deps.chain,
|
||||
supplierKey: deps.supplierKey,
|
||||
escrowRef,
|
||||
});
|
||||
} catch (err) {
|
||||
return { kind: "build_failed" as const, err };
|
||||
}
|
||||
try {
|
||||
await deps.chain.awaitTx(built.expectedTxHash, 60_000);
|
||||
} catch (err) {
|
||||
return { kind: "await_failed" as const, err };
|
||||
}
|
||||
return { kind: "ok" as const, built };
|
||||
});
|
||||
if (claimOutcome.kind === "build_failed") {
|
||||
deps.state.release(escrowRefStr);
|
||||
acquiredRef = null;
|
||||
triggerOnFailureConsolidate({
|
||||
chain: deps.chain,
|
||||
state: deps.state,
|
||||
supplierKey: deps.supplierKey,
|
||||
});
|
||||
return jsonError(res, 503, "chain_submit_failed",
|
||||
`Claim tx submit failed: ${(claimOutcome.err as Error).message}`);
|
||||
}
|
||||
if (claimOutcome.kind === "await_failed") {
|
||||
deps.state.release(escrowRefStr);
|
||||
acquiredRef = null;
|
||||
return jsonError(res, 504, "claim_timeout",
|
||||
`Claim awaitTx failed: ${(claimOutcome.err as Error).message}`);
|
||||
}
|
||||
|
||||
// ── 10. Spawn runner + 202 ──────────────────────────────────────
|
||||
const claimedRef: OutputReference = {
|
||||
txHash: claimOutcome.built.expectedTxHash,
|
||||
index: 0,
|
||||
};
|
||||
const jobId = deps.jobs.create(escrowRefStr);
|
||||
|
||||
// Slot release now happens inside runOcrJob's finally.
|
||||
acquiredRef = null;
|
||||
void runOcrJob({
|
||||
deps: {
|
||||
chain: deps.chain,
|
||||
state: deps.state,
|
||||
config: deps.config,
|
||||
supplierKey: deps.supplierKey,
|
||||
jobs: deps.jobs,
|
||||
},
|
||||
jobId,
|
||||
escrowRef: escrowRefStr,
|
||||
claimedRef,
|
||||
advert,
|
||||
escrowDatum,
|
||||
requestBody: ocrRequest,
|
||||
});
|
||||
|
||||
return res.status(202).json({
|
||||
job_id: jobId,
|
||||
status: "accepted",
|
||||
escrow_ref: escrowRefStr,
|
||||
});
|
||||
} catch (err) {
|
||||
if (acquiredRef) {
|
||||
try { deps.state.release(acquiredRef); } catch { /* ignore */ }
|
||||
}
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ─── GET /v1/ocr/extract/:jobId ─────────────────────────────────────────────
|
||||
function makeGetOcrJobHandler(deps: ResolvedDeps) {
|
||||
return (req: Request, res: Response) => {
|
||||
const rawJobId = req.params.jobId;
|
||||
const jobId = typeof rawJobId === "string" ? rawJobId : "";
|
||||
if (!UUID_V4_RE.test(jobId)) {
|
||||
return jsonError(res, 400, "invalid_job_id", "jobId must be a UUIDv4 string");
|
||||
}
|
||||
const record = deps.jobs.get(jobId);
|
||||
if (!record) {
|
||||
return jsonError(res, 404, "job_not_found", `no job found with id ${jobId}`);
|
||||
}
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
if (record.status === "accepted" || record.status === "running") {
|
||||
return res.status(202).json({
|
||||
status: record.status,
|
||||
escrow_ref: record.escrowRef,
|
||||
});
|
||||
}
|
||||
if (record.status === "done") {
|
||||
const payload = record.responsePayload!;
|
||||
if (!("kind" in payload) || payload.kind !== "ocr") {
|
||||
return jsonError(res, 500, "wrong_payload_kind",
|
||||
"non-ocr payload returned to ocr poll route");
|
||||
}
|
||||
return res.status(200).json({
|
||||
output_format: payload.output_format,
|
||||
content: payload.content,
|
||||
usage: payload.usage,
|
||||
receipt: payload.receipt,
|
||||
receipt_signature: payload.receipt_signature,
|
||||
escrow_ref: record.escrowRef,
|
||||
});
|
||||
}
|
||||
// failed
|
||||
const f = record.failure!;
|
||||
return res.status(f.httpStatus).json({
|
||||
status: "failed",
|
||||
reason: f.reason,
|
||||
message: f.message,
|
||||
escrow_ref: record.escrowRef,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// ─── /v1/chat/{start,message,end} — multi-turn chat session (llm.chat.v1) ───
|
||||
//
|
||||
// Escrow bookends with off-chain turns:
|
||||
@@ -1077,7 +1381,11 @@ export function createApp(deps: SupplierDeps): Application {
|
||||
|
||||
const app = express();
|
||||
app.disable("x-powered-by");
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
// /v1/ocr carries a base64 page image and mounts its own larger parser
|
||||
// below; every other route keeps the 1mb ceiling.
|
||||
const jsonBody = express.json({ limit: "1mb" });
|
||||
app.use((req, res, next) =>
|
||||
req.path.startsWith("/v1/ocr/") ? next() : jsonBody(req, res, next));
|
||||
|
||||
// /healthz is mounted FIRST and uses no deps — it must succeed regardless
|
||||
// of chain/state/config (independent of /status free/working/offline).
|
||||
@@ -1095,6 +1403,11 @@ export function createApp(deps: SupplierDeps): Application {
|
||||
if (resolved.config.capabilityKind === "tts") {
|
||||
app.post("/v1/audio/synthesize", makeTtsHandler(resolved));
|
||||
app.get("/v1/audio/synthesize/:jobId", makeGetTtsJobHandler(resolved));
|
||||
} else if (resolved.config.capabilityKind === "ocr") {
|
||||
// MAX_OCR_IMAGE_B64_CHARS (~12M chars ≈ 9 MB binary) + JSON envelope
|
||||
// fits comfortably under 16mb.
|
||||
app.post("/v1/ocr/extract", express.json({ limit: "16mb" }), makeOcrHandler(resolved));
|
||||
app.get("/v1/ocr/extract/:jobId", makeGetOcrJobHandler(resolved));
|
||||
} else if (resolved.config.capabilityKind === "chat-session") {
|
||||
const chat = makeChatSessionHandlers(resolved);
|
||||
app.post("/v1/chat/start", chat.start);
|
||||
|
||||
@@ -7,15 +7,26 @@
|
||||
* and the lane stalls until an operator runs `tx:consolidate-wallet`.
|
||||
*
|
||||
* Two layers of self-healing:
|
||||
* startWalletHealthTicker — periodic background tick (every N ms). When
|
||||
* the supplier is idle, runs runConsolidateWallet. planConsolidate
|
||||
* returns `already-healthy` cheaply for clean 2-UTxO wallets, so this
|
||||
* is a no-op in steady state — no tx, no fee.
|
||||
* startWalletHealthTicker — periodic background tick (every N ms). Runs
|
||||
* runConsolidateWallet; planConsolidate's structural health predicate
|
||||
* short-circuits without a tx when a collateral candidate exists and
|
||||
* fragmentation is bounded. A tick on a healthy wallet costs one UTxO
|
||||
* query and nothing else.
|
||||
* triggerOnFailureConsolidate — fire-and-forget consolidate from the
|
||||
* Claim-failure path. Debounced so back-to-back failures don't queue
|
||||
* multiple consolidates. Returns 503 to the buyer for THIS request; the
|
||||
* next buyer retry (~30-60s later) finds the wallet healthy.
|
||||
*
|
||||
* F7 guards (2026-08-08 incident: a non-idempotent health check made every
|
||||
* tick consolidate, burning 0.5 AP3X per 45s until the wallet hit the
|
||||
* floor — 116 AP3X in one soak). Two independent circuit breakers protect
|
||||
* against ANY future non-convergence:
|
||||
* - consecutive guard: 3 consolidations in a row without ever reaching
|
||||
* `already-healthy` halts wallet-health for this process.
|
||||
* - budget guard: more than 6 consolidations inside 24h halts it too.
|
||||
* A halt logs loudly once; consolidate manually and restart the supplier
|
||||
* to re-arm.
|
||||
*
|
||||
* Wallet-lock discipline: consolidation spends EVERY wallet UTxO, so it runs
|
||||
* inside state.walletMutex — the same mutex that serializes Claims/Submits —
|
||||
* queueing behind any in-flight chain op instead of skipping. Session slots
|
||||
@@ -63,6 +74,29 @@ export interface WalletHealthTicker {
|
||||
const inFlight = new WeakSet<SupplierState>();
|
||||
const lastOnFailureAt = new WeakMap<SupplierState, number>();
|
||||
|
||||
// ── F7 circuit breakers ──────────────────────────────────────────────
|
||||
const MAX_CONSECUTIVE_CONSOLIDATIONS = 3;
|
||||
const MAX_CONSOLIDATIONS_PER_WINDOW = 6;
|
||||
const BUDGET_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface GuardCounters {
|
||||
consecutive: number;
|
||||
windowStart: number;
|
||||
windowCount: number;
|
||||
halted: boolean;
|
||||
}
|
||||
|
||||
const guardCounters = new WeakMap<SupplierState, GuardCounters>();
|
||||
|
||||
function guardsFor(state: SupplierState): GuardCounters {
|
||||
let g = guardCounters.get(state);
|
||||
if (!g) {
|
||||
g = { consecutive: 0, windowStart: Date.now(), windowCount: 0, halted: false };
|
||||
guardCounters.set(state, g);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
function defaultLog(line: string): void {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[wallet-health] ${line}`);
|
||||
@@ -73,6 +107,8 @@ async function consolidateOnce(
|
||||
opts: { collateralLovelace: bigint; awaitTimeoutMs: number; log: (line: string) => void },
|
||||
): Promise<void> {
|
||||
const { state } = deps;
|
||||
const guards = guardsFor(state);
|
||||
if (guards.halted) return; // the halt already logged loudly, once
|
||||
if (inFlight.has(state)) {
|
||||
opts.log("skip: another consolidate already in flight");
|
||||
return;
|
||||
@@ -92,11 +128,36 @@ async function consolidateOnce(
|
||||
}),
|
||||
);
|
||||
if (result.reason === "already-healthy") {
|
||||
guards.consecutive = 0;
|
||||
opts.log("already-healthy");
|
||||
} else {
|
||||
const now = Date.now();
|
||||
if (now - guards.windowStart > BUDGET_WINDOW_MS) {
|
||||
guards.windowStart = now;
|
||||
guards.windowCount = 0;
|
||||
}
|
||||
guards.consecutive += 1;
|
||||
guards.windowCount += 1;
|
||||
opts.log(`consolidated: ${result.reason} txHash=${result.txHash}`);
|
||||
if (guards.consecutive >= MAX_CONSECUTIVE_CONSOLIDATIONS) {
|
||||
guards.halted = true;
|
||||
opts.log(
|
||||
`HALT: ${guards.consecutive} consecutive consolidations without reaching a healthy shape — ` +
|
||||
`wallet-health stopped to protect funds (F7 guard). Investigate planConsolidate vs the ` +
|
||||
`builder's output shape, consolidate manually, restart the supplier to re-arm.`,
|
||||
);
|
||||
} else if (guards.windowCount >= MAX_CONSOLIDATIONS_PER_WINDOW) {
|
||||
guards.halted = true;
|
||||
opts.log(
|
||||
`HALT: consolidation budget exhausted (${guards.windowCount} in 24h) — wallet-health ` +
|
||||
`stopped to protect funds (F7 guard). Restart the supplier to re-arm.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Neither healthy nor consolidated (e.g. balance too low): no fee was
|
||||
// spent, so the guards don't move — but nothing converged either, so
|
||||
// the consecutive counter is NOT reset.
|
||||
opts.log(`consolidate failed: ${(err as Error).message}`);
|
||||
} finally {
|
||||
inFlight.delete(state);
|
||||
@@ -143,8 +204,9 @@ export function triggerOnFailureConsolidate(
|
||||
});
|
||||
}
|
||||
|
||||
/** Test-only: reset the in-flight + debounce maps between tests. */
|
||||
/** Test-only: reset the in-flight + debounce + guard maps between tests. */
|
||||
export function _resetWalletHealthForTests(state: SupplierState): void {
|
||||
inFlight.delete(state);
|
||||
lastOnFailureAt.delete(state);
|
||||
guardCounters.delete(state);
|
||||
}
|
||||
|
||||
34
tests/fixtures/supplier-side/sample-config.ts
vendored
34
tests/fixtures/supplier-side/sample-config.ts
vendored
@@ -47,6 +47,40 @@ export function buildSampleTtsConfig(): SupplierConfig {
|
||||
};
|
||||
}
|
||||
|
||||
/** OCR variant: OpenAI-compatible vision upstream, capabilityKind="ocr". */
|
||||
export function buildSampleOcrConfig(): SupplierConfig {
|
||||
return {
|
||||
...buildSampleConfig(),
|
||||
ollamaUrl: "",
|
||||
openaiBaseUrl: "http://vllm.fake:8000",
|
||||
openaiApiKey: "",
|
||||
openaiTimeoutMs: 180_000,
|
||||
openaiMaxTokens: 0,
|
||||
openaiReasoningDisabled: false,
|
||||
capabilityKind: "ocr",
|
||||
ocrUpstream: "openai-vision",
|
||||
datalabApiKey: "",
|
||||
datalabBaseUrl: "https://www.datalab.to",
|
||||
datalabMode: "accurate",
|
||||
datalabTimeoutMs: 120_000,
|
||||
ocrPromptOverride: "",
|
||||
hfModelRevision: "",
|
||||
revisionProbeUrl: "",
|
||||
revisionProbeMode: "off",
|
||||
};
|
||||
}
|
||||
|
||||
/** Datalab-hosted OCR variant (OCR_UPSTREAM=datalab). */
|
||||
export function buildSampleDatalabOcrConfig(): SupplierConfig {
|
||||
return {
|
||||
...buildSampleOcrConfig(),
|
||||
openaiBaseUrl: "",
|
||||
ocrUpstream: "datalab",
|
||||
datalabApiKey: "dl-test-key",
|
||||
revisionProbeMode: "per_job",
|
||||
};
|
||||
}
|
||||
|
||||
/** Env map matching the sample config above, for loadConfig() unit tests. */
|
||||
export function buildSampleEnv(): Record<string, string> {
|
||||
return {
|
||||
|
||||
217
tests/unit/supplier-datalab-ocr.test.ts
Normal file
217
tests/unit/supplier-datalab-ocr.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* supplier-datalab-ocr.test.ts — coverage for the Datalab hosted-API OCR
|
||||
* upstream (OCR_UPSTREAM="datalab").
|
||||
*
|
||||
* Pins the wire contract verified against documentation.datalab.to
|
||||
* (2026-08-06): multipart POST /api/v1/convert with X-API-Key, then poll
|
||||
* GET /api/v1/convert/{id} until status "complete"; result field named by
|
||||
* output_format. Plus the error taxonomy and the health probe.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
callDatalabOcr,
|
||||
probeDatalabHealth,
|
||||
DatalabError,
|
||||
} from "../../supplier/src/datalabOcr.js";
|
||||
import { loadConfig } from "../../supplier/src/config.js";
|
||||
import { buildSampleEnv } from "../fixtures/supplier-side/sample-config.js";
|
||||
|
||||
const IMG = Buffer.from("page bytes").toString("base64");
|
||||
|
||||
function validParams(overrides?: Partial<Parameters<typeof callDatalabOcr>[0]>) {
|
||||
return {
|
||||
baseUrl: "https://dl.fake",
|
||||
apiKey: "dl-key",
|
||||
request: { image_b64: IMG, mime: "image/png", output_format: "markdown" },
|
||||
timeoutMs: 5_000,
|
||||
mode: "accurate",
|
||||
pollIntervalMs: 5,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("callDatalabOcr — happy path + wire shape", () => {
|
||||
it("submits multipart with X-API-Key, polls, returns the markdown content", async () => {
|
||||
const calls: Array<{ url: string; init: RequestInit }> = [];
|
||||
let polls = 0;
|
||||
vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
|
||||
calls.push({ url, init: init ?? {} });
|
||||
if (url.endsWith("/api/v1/convert")) {
|
||||
return new Response(JSON.stringify({
|
||||
success: true, error: null, request_id: "req-1",
|
||||
request_check_url: "https://dl.fake/api/v1/convert/req-1",
|
||||
}), { status: 200 });
|
||||
}
|
||||
polls += 1;
|
||||
if (polls < 2) {
|
||||
return new Response(JSON.stringify({ status: "processing" }), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify({
|
||||
status: "complete", markdown: "# Registry page", page_count: 1, error: null,
|
||||
}), { status: 200 });
|
||||
});
|
||||
|
||||
const result = await callDatalabOcr(validParams());
|
||||
expect(result.content).toBe("# Registry page");
|
||||
expect(result.prompt_tokens).toBe(0);
|
||||
expect(result.completion_tokens).toBe(0);
|
||||
expect(result.wallclock_ms).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const submit = calls[0];
|
||||
expect(submit.url).toBe("https://dl.fake/api/v1/convert");
|
||||
expect((submit.init.headers as Record<string, string>)["X-API-Key"]).toBe("dl-key");
|
||||
const form = submit.init.body as FormData;
|
||||
expect(form).toBeInstanceOf(FormData);
|
||||
expect(form.get("output_format")).toBe("markdown");
|
||||
expect(form.get("mode")).toBe("accurate");
|
||||
const file = form.get("file") as Blob;
|
||||
expect(file).toBeInstanceOf(Blob);
|
||||
expect(file.type).toBe("image/png");
|
||||
|
||||
// Poll used the check URL with the key.
|
||||
const poll = calls[1];
|
||||
expect(poll.url).toBe("https://dl.fake/api/v1/convert/req-1");
|
||||
expect((poll.init.headers as Record<string, string>)["X-API-Key"]).toBe("dl-key");
|
||||
});
|
||||
|
||||
it("stringifies an object result for output_format=json", async () => {
|
||||
vi.stubGlobal("fetch", async (url: string) => {
|
||||
if (url.endsWith("/api/v1/convert")) {
|
||||
return new Response(JSON.stringify({ success: true, request_id: "r2", request_check_url: "https://dl.fake/api/v1/convert/r2" }), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify({
|
||||
status: "complete", json: { blocks: [{ type: "Table" }] },
|
||||
}), { status: 200 });
|
||||
});
|
||||
const result = await callDatalabOcr(validParams({
|
||||
request: { image_b64: IMG, mime: "image/png", output_format: "json" },
|
||||
}));
|
||||
expect(result.content).toBe(JSON.stringify({ blocks: [{ type: "Table" }] }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("callDatalabOcr — error taxonomy", () => {
|
||||
it("datalab_failure on submit HTTP error", async () => {
|
||||
vi.stubGlobal("fetch", async () => new Response("no", { status: 500 }));
|
||||
await expect(callDatalabOcr(validParams())).rejects.toMatchObject({
|
||||
name: "DatalabError", reason: "datalab_failure",
|
||||
});
|
||||
});
|
||||
|
||||
it("datalab_failure on success:false", async () => {
|
||||
vi.stubGlobal("fetch", async () =>
|
||||
new Response(JSON.stringify({ success: false, error: "no credits" }), { status: 200 }));
|
||||
await expect(callDatalabOcr(validParams())).rejects.toThrow(/no credits/);
|
||||
});
|
||||
|
||||
it("datalab_malformed when request_id missing", async () => {
|
||||
vi.stubGlobal("fetch", async () =>
|
||||
new Response(JSON.stringify({ success: true }), { status: 200 }));
|
||||
await expect(callDatalabOcr(validParams())).rejects.toMatchObject({
|
||||
reason: "datalab_malformed",
|
||||
});
|
||||
});
|
||||
|
||||
it("datalab_failure when the job reports failed status", async () => {
|
||||
vi.stubGlobal("fetch", async (url: string) => {
|
||||
if (url.endsWith("/api/v1/convert")) {
|
||||
return new Response(JSON.stringify({ success: true, request_id: "r3", request_check_url: "https://dl.fake/api/v1/convert/r3" }), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify({ status: "failed", error: "unreadable file" }), { status: 200 });
|
||||
});
|
||||
await expect(callDatalabOcr(validParams())).rejects.toThrow(/unreadable file/);
|
||||
});
|
||||
|
||||
it("datalab_malformed when the completed result lacks the requested format field", async () => {
|
||||
vi.stubGlobal("fetch", async (url: string) => {
|
||||
if (url.endsWith("/api/v1/convert")) {
|
||||
return new Response(JSON.stringify({ success: true, request_id: "r4", request_check_url: "https://dl.fake/api/v1/convert/r4" }), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify({ status: "complete", html: "<p>x</p>" }), { status: 200 });
|
||||
});
|
||||
await expect(callDatalabOcr(validParams())).rejects.toMatchObject({
|
||||
reason: "datalab_malformed",
|
||||
});
|
||||
});
|
||||
|
||||
it("datalab_timeout when polling never completes within the budget", async () => {
|
||||
vi.stubGlobal("fetch", async (url: string) => {
|
||||
if (url.endsWith("/api/v1/convert")) {
|
||||
return new Response(JSON.stringify({ success: true, request_id: "r5", request_check_url: "https://dl.fake/api/v1/convert/r5" }), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify({ status: "processing" }), { status: 200 });
|
||||
});
|
||||
await expect(callDatalabOcr(validParams({ timeoutMs: 60, pollIntervalMs: 10 })))
|
||||
.rejects.toMatchObject({ reason: "datalab_timeout" });
|
||||
});
|
||||
|
||||
it("exports the error class for instanceof checks", async () => {
|
||||
vi.stubGlobal("fetch", async () => new Response("x", { status: 502 }));
|
||||
try {
|
||||
await callDatalabOcr(validParams());
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DatalabError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeDatalabHealth", () => {
|
||||
it("ok on 200 from /api/v1/health with the key header", async () => {
|
||||
let captured: { url: string; init: RequestInit } | null = null;
|
||||
vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
|
||||
captured = { url, init: init ?? {} };
|
||||
return new Response(JSON.stringify({ status: "ok" }), { status: 200 });
|
||||
});
|
||||
const result = await probeDatalabHealth({ baseUrl: "https://dl.fake", apiKey: "dl-key", timeoutMs: 1000 });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(captured!.url).toBe("https://dl.fake/api/v1/health");
|
||||
expect((captured!.init.headers as Record<string, string>)["X-API-Key"]).toBe("dl-key");
|
||||
});
|
||||
|
||||
it("not ok on HTTP error or network failure", async () => {
|
||||
vi.stubGlobal("fetch", async () => new Response("x", { status: 503 }));
|
||||
expect((await probeDatalabHealth({ baseUrl: "https://dl.fake", apiKey: "k", timeoutMs: 1000 })).ok).toBe(false);
|
||||
vi.stubGlobal("fetch", async () => { throw new Error("ECONNREFUSED"); });
|
||||
expect((await probeDatalabHealth({ baseUrl: "https://dl.fake", apiKey: "k", timeoutMs: 1000 })).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadConfig — datalab upstream validation", () => {
|
||||
function ocrEnv(extra: Record<string, string>): Record<string, string> {
|
||||
const env = buildSampleEnv();
|
||||
delete (env as Record<string, string | undefined>).OLLAMA_URL;
|
||||
return { ...env, CAPABILITY_KIND: "ocr", ...extra };
|
||||
}
|
||||
|
||||
it("requires DATALAB_API_KEY when OCR_UPSTREAM=datalab", () => {
|
||||
expect(() => loadConfig(ocrEnv({ OCR_UPSTREAM: "datalab" })))
|
||||
.toThrow(/DATALAB_API_KEY/);
|
||||
});
|
||||
|
||||
it("datalab upstream boots without OPENAI_BASE_URL and probes per_job by default", () => {
|
||||
const config = loadConfig(ocrEnv({ OCR_UPSTREAM: "datalab", DATALAB_API_KEY: "dl-k" }));
|
||||
expect(config.ocrUpstream).toBe("datalab");
|
||||
expect(config.openaiBaseUrl).toBe("");
|
||||
expect(config.revisionProbeMode).toBe("per_job");
|
||||
expect(config.datalabBaseUrl).toBe("https://www.datalab.to");
|
||||
expect(config.datalabMode).toBe("accurate");
|
||||
});
|
||||
|
||||
it("openai-vision upstream still requires OPENAI_BASE_URL", () => {
|
||||
expect(() => loadConfig(ocrEnv({})))
|
||||
.toThrow(/OPENAI_BASE_URL/);
|
||||
});
|
||||
|
||||
it("rejects an invalid DATALAB_MODE", () => {
|
||||
expect(() => loadConfig(ocrEnv({
|
||||
OCR_UPSTREAM: "datalab", DATALAB_API_KEY: "dl-k", DATALAB_MODE: "turbo",
|
||||
}))).toThrow(/DATALAB_MODE/);
|
||||
});
|
||||
});
|
||||
164
tests/unit/supplier-ocr-vision.test.ts
Normal file
164
tests/unit/supplier-ocr-vision.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* supplier-ocr-vision.test.ts — coverage for the OpenAI-compatible VISION
|
||||
* upstream client used by the `ocr` capability kind.
|
||||
*
|
||||
* Mirrors supplier-openai.test.ts: stub global fetch, pin the outbound wire
|
||||
* shape (vision content parts, data-URL image), and the error taxonomy
|
||||
* (ocr_failure / ocr_timeout / ocr_malformed).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
callOcrVision,
|
||||
probeRevision,
|
||||
defaultOcrInstruction,
|
||||
OcrVisionError,
|
||||
} from "../../supplier/src/ocrVision.js";
|
||||
|
||||
const IMG = Buffer.from("some page bytes").toString("base64");
|
||||
|
||||
function validParams() {
|
||||
return {
|
||||
baseUrl: "http://vllm.fake:8000",
|
||||
model: "datalab-to/chandra-ocr-2",
|
||||
request: { image_b64: IMG, mime: "image/png", output_format: "markdown" },
|
||||
timeoutMs: 5_000,
|
||||
};
|
||||
}
|
||||
|
||||
function okBody(content = "# Page\ntext") {
|
||||
return {
|
||||
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 900, completion_tokens: 120, total_tokens: 1020 },
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("callOcrVision — happy path + wire shape", () => {
|
||||
it("POSTs vision content parts with a data-URL image and returns the normalised result", async () => {
|
||||
let captured: { url: string; init: RequestInit } | null = null;
|
||||
vi.stubGlobal("fetch", async (url: string, init: RequestInit) => {
|
||||
captured = { url, init };
|
||||
return new Response(JSON.stringify(okBody()), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
const result = await callOcrVision(validParams());
|
||||
|
||||
expect(result.content).toBe("# Page\ntext");
|
||||
expect(result.prompt_tokens).toBe(900);
|
||||
expect(result.completion_tokens).toBe(120);
|
||||
expect(result.wallclock_ms).toBeGreaterThanOrEqual(0);
|
||||
|
||||
expect(captured).not.toBeNull();
|
||||
expect(captured!.url).toBe("http://vllm.fake:8000/v1/chat/completions");
|
||||
const sent = JSON.parse(String(captured!.init.body));
|
||||
expect(sent.model).toBe("datalab-to/chandra-ocr-2");
|
||||
expect(sent.stream).toBe(false);
|
||||
expect(Array.isArray(sent.messages)).toBe(true);
|
||||
const content = sent.messages[0].content;
|
||||
expect(content[0].type).toBe("image_url");
|
||||
expect(content[0].image_url.url).toBe(`data:image/png;base64,${IMG}`);
|
||||
expect(content[1].type).toBe("text");
|
||||
expect(content[1].text).toBe(defaultOcrInstruction("markdown"));
|
||||
});
|
||||
|
||||
it("uses promptOverride verbatim when provided", async () => {
|
||||
let sentText = "";
|
||||
vi.stubGlobal("fetch", async (_url: string, init: RequestInit) => {
|
||||
sentText = JSON.parse(String(init.body)).messages[0].content[1].text;
|
||||
return new Response(JSON.stringify(okBody()), { status: 200 });
|
||||
});
|
||||
await callOcrVision({ ...validParams(), promptOverride: "OCR EXACTLY AS CHANDRA WANTS" });
|
||||
expect(sentText).toBe("OCR EXACTLY AS CHANDRA WANTS");
|
||||
});
|
||||
|
||||
it("forwards max_tokens when positive and the bearer key when set", async () => {
|
||||
let sent: Record<string, unknown> = {};
|
||||
let auth = "";
|
||||
vi.stubGlobal("fetch", async (_url: string, init: RequestInit) => {
|
||||
sent = JSON.parse(String(init.body));
|
||||
auth = (init.headers as Record<string, string>).authorization ?? "";
|
||||
return new Response(JSON.stringify(okBody()), { status: 200 });
|
||||
});
|
||||
await callOcrVision({ ...validParams(), maxTokens: 4096, apiKey: "sk-test" });
|
||||
expect(sent.max_tokens).toBe(4096);
|
||||
expect(auth).toBe("Bearer sk-test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("callOcrVision — error taxonomy", () => {
|
||||
it("ocr_failure on non-2xx", async () => {
|
||||
vi.stubGlobal("fetch", async () => new Response("boom", { status: 500 }));
|
||||
await expect(callOcrVision(validParams())).rejects.toMatchObject({
|
||||
name: "OcrVisionError",
|
||||
reason: "ocr_failure",
|
||||
});
|
||||
});
|
||||
|
||||
it("ocr_malformed on missing content", async () => {
|
||||
vi.stubGlobal("fetch", async () =>
|
||||
new Response(JSON.stringify({ choices: [{ message: { role: "assistant", content: "" } }] }), { status: 200 }));
|
||||
await expect(callOcrVision(validParams())).rejects.toMatchObject({
|
||||
reason: "ocr_malformed",
|
||||
});
|
||||
});
|
||||
|
||||
it("ocr_timeout when the upstream exceeds timeoutMs", async () => {
|
||||
vi.stubGlobal("fetch", (_url: string, init: RequestInit) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
const signal = init.signal as AbortSignal;
|
||||
signal.addEventListener("abort", () => {
|
||||
const err = new Error("aborted");
|
||||
err.name = "AbortError";
|
||||
reject(err);
|
||||
});
|
||||
}));
|
||||
await expect(callOcrVision({ ...validParams(), timeoutMs: 30 })).rejects.toMatchObject({
|
||||
reason: "ocr_timeout",
|
||||
});
|
||||
});
|
||||
|
||||
it("exports the error class for instanceof checks", async () => {
|
||||
vi.stubGlobal("fetch", async () => new Response("nope", { status: 502 }));
|
||||
try {
|
||||
await callOcrVision(validParams());
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OcrVisionError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeRevision — served-checkpoint probe", () => {
|
||||
it("extracts a 40-hex sha from known runtime fields", async () => {
|
||||
const sha = "a".repeat(40);
|
||||
vi.stubGlobal("fetch", async () =>
|
||||
new Response(JSON.stringify({ model_sha: sha }), { status: 200 }));
|
||||
const result = await probeRevision({ probeUrl: "http://vllm.fake:8000/info", timeoutMs: 1000 });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.servedSha).toBe(sha);
|
||||
});
|
||||
|
||||
it("ok with empty sha when the runtime reports no revision field", async () => {
|
||||
vi.stubGlobal("fetch", async () =>
|
||||
new Response(JSON.stringify({ served_model: "chandra" }), { status: 200 }));
|
||||
const result = await probeRevision({ probeUrl: "http://x/info", timeoutMs: 1000 });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.servedSha).toBe("");
|
||||
});
|
||||
|
||||
it("not ok on HTTP error / network failure", async () => {
|
||||
vi.stubGlobal("fetch", async () => new Response("x", { status: 503 }));
|
||||
expect((await probeRevision({ probeUrl: "http://x/info", timeoutMs: 1000 })).ok).toBe(false);
|
||||
|
||||
vi.stubGlobal("fetch", async () => { throw new Error("ECONNREFUSED"); });
|
||||
expect((await probeRevision({ probeUrl: "http://x/info", timeoutMs: 1000 })).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
176
tests/unit/supplier-routes-ocr.test.ts
Normal file
176
tests/unit/supplier-routes-ocr.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* supplier-routes-ocr.test.ts — coverage for the model-scoped OCR supplier
|
||||
* route (`ocr.page.extract.<model-slug>.v1`).
|
||||
*
|
||||
* Focus (mirrors supplier-routes-tts.test.ts): the NEW validation logic in
|
||||
* `makeOcrHandler` plus capability-routed mounting and the enlarged body
|
||||
* limit. The shared lifecycle pieces (lock, claim tx, runner spawn) reuse
|
||||
* the chat-handler implementation and are covered by the chat route tests.
|
||||
*
|
||||
* End-to-end happy-path (escrow seed → claim → run → submit) is exercised
|
||||
* by the post-deploy smoke against the real testnet, not here.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import request from "supertest";
|
||||
import type { Application } from "express";
|
||||
|
||||
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
|
||||
import { SupplierState } from "../../supplier/src/state.js";
|
||||
import { JobStore } from "../../supplier/src/jobs.js";
|
||||
import { createApp } from "../../supplier/src/server.js";
|
||||
import type { SupplierConfig } from "../../supplier/src/config.js";
|
||||
import {
|
||||
buildSampleConfig,
|
||||
buildSampleOcrConfig,
|
||||
} from "../fixtures/supplier-side/sample-config.js";
|
||||
import { buildSupplierWalletKey } from "../fixtures/supplier-side/wallet-keys.js";
|
||||
|
||||
const ESCROW_REF_HEADER = `${"f".repeat(64)}#0`;
|
||||
const IMG = Buffer.from("page image bytes").toString("base64");
|
||||
|
||||
function makeApp(config?: SupplierConfig): Application {
|
||||
return createApp({
|
||||
chain: new MockChainProvider(),
|
||||
state: new SupplierState(),
|
||||
config: config ?? buildSampleOcrConfig(),
|
||||
supplierKey: buildSupplierWalletKey(),
|
||||
jobs: new JobStore(),
|
||||
});
|
||||
}
|
||||
|
||||
function validBody() {
|
||||
return { image_b64: IMG, mime: "image/png", output_format: "markdown" };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Capability-routed mounting", () => {
|
||||
it("ocr supplier exposes /v1/ocr/extract and NOT the chat/tts routes", async () => {
|
||||
const app = makeApp();
|
||||
const ocr = await request(app).post("/v1/ocr/extract")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send(validBody());
|
||||
expect(ocr.status).not.toBe(404);
|
||||
|
||||
const chat = await request(app).post("/v1/chat/completions")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
|
||||
.send({ messages: [{ role: "user", content: "hi" }] });
|
||||
expect(chat.status).toBe(404);
|
||||
|
||||
const tts = await request(app).post("/v1/audio/synthesize")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
|
||||
.send({ text: "x", voice: "nova", format: "mp3", speed: 1 });
|
||||
expect(tts.status).toBe(404);
|
||||
});
|
||||
|
||||
it("chat supplier does NOT expose /v1/ocr/extract", async () => {
|
||||
const app = makeApp(buildSampleConfig());
|
||||
const res = await request(app).post("/v1/ocr/extract")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send(validBody());
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v1/ocr/extract — header + body validation", () => {
|
||||
it("400 escrow_ref_required when X-Escrow-Ref header missing", async () => {
|
||||
const res = await request(makeApp()).post("/v1/ocr/extract").send(validBody());
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.reason).toBe("escrow_ref_required");
|
||||
});
|
||||
|
||||
it("400 escrow_ref_malformed when header is not <64hex>#<int>", async () => {
|
||||
const res = await request(makeApp()).post("/v1/ocr/extract")
|
||||
.set("X-Escrow-Ref", "not-a-ref").send(validBody());
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.reason).toBe("escrow_ref_malformed");
|
||||
});
|
||||
|
||||
it("400 image_required when image_b64 missing or empty", async () => {
|
||||
const app = makeApp();
|
||||
for (const body of [{}, { image_b64: "" }, { mime: "image/png", output_format: "markdown" }]) {
|
||||
const res = await request(app).post("/v1/ocr/extract")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send(body);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.reason).toBe("image_required");
|
||||
}
|
||||
});
|
||||
|
||||
it("400 image_not_base64 for data-URL or whitespace payloads", async () => {
|
||||
const app = makeApp();
|
||||
for (const image_b64 of [`data:image/png;base64,${IMG}`, `${IMG} `, "has space"]) {
|
||||
const res = await request(app).post("/v1/ocr/extract")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
|
||||
.send({ ...validBody(), image_b64 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.reason).toBe("image_not_base64");
|
||||
}
|
||||
});
|
||||
|
||||
it("400 mime_invalid for unsupported mime", async () => {
|
||||
const res = await request(makeApp()).post("/v1/ocr/extract")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
|
||||
.send({ ...validBody(), mime: "image/tiff" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.reason).toBe("mime_invalid");
|
||||
});
|
||||
|
||||
it("400 output_format_invalid for unsupported format", async () => {
|
||||
const res = await request(makeApp()).post("/v1/ocr/extract")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
|
||||
.send({ ...validBody(), output_format: "yaml" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.reason).toBe("output_format_invalid");
|
||||
});
|
||||
|
||||
it("accepts a 2MB body (over the 1mb global limit — OCR route has its own parser)", async () => {
|
||||
// 2M chars of base64 ≈ 1.5 MB binary; global express.json would 413 this.
|
||||
const big = "A".repeat(2_000_000);
|
||||
const res = await request(makeApp()).post("/v1/ocr/extract")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
|
||||
.send({ ...validBody(), image_b64: big });
|
||||
// Passes body parsing + validation, then fails on the unseeded chain
|
||||
// (advert_unavailable 503) — anything but 413 proves the parser took it.
|
||||
expect(res.status).not.toBe(413);
|
||||
expect([403, 404, 503]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v1/ocr/extract — revision-probe gate", () => {
|
||||
it("503 advert_unavailable — chain validation runs before the revision probe fires", async () => {
|
||||
// Advert/escrow would resolve AFTER the probe? No — probe runs at step
|
||||
// 7.5, after chain checks. Seeding a full escrow in the mock provider is
|
||||
// the chat-route suite's job; here we assert the config gate wiring by
|
||||
// hitting the earlier chain-side failure with probing configured — the
|
||||
// probe must NOT fire before chain validation (unseeded chain 503s
|
||||
// first), keeping probe cost off the hot invalid path.
|
||||
vi.stubGlobal("fetch", async () => { throw new Error("probe must not be called yet"); });
|
||||
const config: SupplierConfig = {
|
||||
...buildSampleOcrConfig(),
|
||||
revisionProbeUrl: "http://vllm.fake:8000/info",
|
||||
revisionProbeMode: "per_job",
|
||||
hfModelRevision: "a".repeat(40),
|
||||
};
|
||||
const res = await request(makeApp(config)).post("/v1/ocr/extract")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send(validBody());
|
||||
// Unseeded chain → advert_unavailable (503) BEFORE any probe fetch.
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.reason).toBe("advert_unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /v1/ocr/extract/:jobId", () => {
|
||||
it("400 invalid_job_id for non-UUID", async () => {
|
||||
const res = await request(makeApp()).get("/v1/ocr/extract/not-a-uuid");
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.reason).toBe("invalid_job_id");
|
||||
});
|
||||
|
||||
it("404 job_not_found for unknown UUID", async () => {
|
||||
const res = await request(makeApp())
|
||||
.get("/v1/ocr/extract/123e4567-e89b-42d3-a456-426614174000");
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.reason).toBe("job_not_found");
|
||||
});
|
||||
});
|
||||
@@ -242,3 +242,115 @@ describe("walletHealth — triggerOnFailureConsolidate", () => {
|
||||
expect(state.snapshot().status).toBe("working");
|
||||
});
|
||||
});
|
||||
|
||||
describe("walletHealth — F7 circuit breakers", () => {
|
||||
let state: SupplierState;
|
||||
let logLines: string[];
|
||||
let log: (line: string) => void;
|
||||
let consolidate: ReturnType<typeof vi.fn> & ConsolidateFn;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
state = new SupplierState();
|
||||
_resetWalletHealthForTests(state);
|
||||
logLines = [];
|
||||
log = (line) => logLines.push(line);
|
||||
consolidate = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("halts after 3 consecutive consolidations that never reach healthy", async () => {
|
||||
// The 2026-08-07 pump: every tick consolidates, none lands healthy.
|
||||
consolidate.mockResolvedValue(consolidatedResult);
|
||||
|
||||
const ticker = startWalletHealthTicker(
|
||||
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
||||
{ intervalMs: 1_000, log },
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(3));
|
||||
expect(logLines.some((l) => l.includes("HALT") && l.includes("consecutive"))).toBe(true);
|
||||
|
||||
// Two more ticks: the breaker holds, no further txs.
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(consolidate).toHaveBeenCalledTimes(3);
|
||||
|
||||
ticker.stop();
|
||||
});
|
||||
|
||||
it("an already-healthy result resets the consecutive counter", async () => {
|
||||
consolidate
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValueOnce(healthyResult)
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValue(healthyResult);
|
||||
|
||||
const ticker = startWalletHealthTicker(
|
||||
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
||||
{ intervalMs: 1_000, log },
|
||||
);
|
||||
|
||||
// 6 ticks: c,c,h,c,c,h — never 3 consecutive, so never halted.
|
||||
await vi.advanceTimersByTimeAsync(6_000);
|
||||
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(6));
|
||||
expect(logLines.some((l) => l.includes("HALT"))).toBe(false);
|
||||
|
||||
ticker.stop();
|
||||
});
|
||||
|
||||
it("halts when the 24h consolidation budget is exhausted", async () => {
|
||||
// Interleave healthy results so the consecutive guard never fires:
|
||||
// c,c,h,c,c,h,c,c -> 6 consolidations inside the window at tick 8.
|
||||
consolidate
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValueOnce(healthyResult)
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValueOnce(healthyResult)
|
||||
.mockResolvedValue(consolidatedResult);
|
||||
|
||||
const ticker = startWalletHealthTicker(
|
||||
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
||||
{ intervalMs: 1_000, log },
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(8_000);
|
||||
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(8));
|
||||
expect(logLines.some((l) => l.includes("HALT") && l.includes("budget"))).toBe(true);
|
||||
|
||||
// Breaker holds on subsequent ticks.
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(consolidate).toHaveBeenCalledTimes(8);
|
||||
|
||||
ticker.stop();
|
||||
});
|
||||
|
||||
it("failed consolidations neither trip nor reset the breakers", async () => {
|
||||
// Errors spend no fee: they must not count as consolidations, but they
|
||||
// must not clear the consecutive count either.
|
||||
consolidate
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockRejectedValueOnce(new Error("balance too low to consolidate"))
|
||||
.mockResolvedValue(consolidatedResult);
|
||||
|
||||
const ticker = startWalletHealthTicker(
|
||||
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
||||
{ intervalMs: 1_000, log },
|
||||
);
|
||||
|
||||
// c,c,error,c -> the 4th call is the 3rd consecutive consolidation.
|
||||
await vi.advanceTimersByTimeAsync(4_000);
|
||||
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(4));
|
||||
expect(logLines.some((l) => l.includes("HALT") && l.includes("consecutive"))).toBe(true);
|
||||
|
||||
ticker.stop();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,14 +64,17 @@ describe("planConsolidate", () => {
|
||||
expect(plan.workingOutput).toBe(8_650_000n);
|
||||
});
|
||||
|
||||
it("consolidates a 2-UTxO wallet where the smaller is below collateral threshold", () => {
|
||||
// Both fit budget but smaller (3 AP3X) is below 5 AP3X — needs reshape.
|
||||
it("accepts a 2-UTxO wallet where the LARGER is the collateral candidate", () => {
|
||||
// {3, 15}: the 15 qualifies as a pure >=5 candidate and the 3 covers
|
||||
// fees — this wallet can run script txs as-is. The old exact-2-slot
|
||||
// convention reshaped it for nothing (0.5 AP3X per pass).
|
||||
const utxos = [u(3_000_000n), u(15_000_000n)];
|
||||
const plan = planConsolidate(utxos);
|
||||
|
||||
expect(plan.reason).toBe("consolidate");
|
||||
expect(plan.alreadyHealthy).toBe(false);
|
||||
expect(plan.inputCount).toBe(2);
|
||||
expect(plan.reason).toBe("already-healthy");
|
||||
expect(plan.alreadyHealthy).toBe(true);
|
||||
expect(plan.collateralOutput).toBe(15_000_000n);
|
||||
expect(plan.workingOutput).toBe(3_000_000n);
|
||||
});
|
||||
|
||||
it("throws on 0 UTxOs", () => {
|
||||
@@ -93,3 +96,93 @@ describe("planConsolidate", () => {
|
||||
expect(plan.workingOutput).toBe(20_000_000n - 10_000_000n - DEFAULT_FEE_RESERVE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("planConsolidate — structural health predicate (F7)", () => {
|
||||
// The tx buildConsolidateWalletTx actually lands: {collateral 5.0,
|
||||
// working = total - 5.0 - 2.0 reserve, change = reserve - fee}. The fee
|
||||
// observed on mainnet is exactly 501,628, so the change is 1,498,372.
|
||||
const MAINNET_CONSOLIDATE_FEE = 501_628n;
|
||||
|
||||
it("REGRESSION: the exact shape consolidation produces is healthy", () => {
|
||||
// This is the loop that burned 116 AP3X in the 2026-08-07 soak: the
|
||||
// planner called its own output shape unhealthy and re-consolidated
|
||||
// every 45s tick. Feed it precisely what the builder lands.
|
||||
const utxos = [
|
||||
u(5_000_000n),
|
||||
u(91_000_000n),
|
||||
u(DEFAULT_FEE_RESERVE - MAINNET_CONSOLIDATE_FEE), // 1,498,372 change
|
||||
];
|
||||
const plan = planConsolidate(utxos);
|
||||
|
||||
expect(plan.reason).toBe("already-healthy");
|
||||
expect(plan.alreadyHealthy).toBe(true);
|
||||
expect(plan.collateralOutput).toBe(5_000_000n);
|
||||
});
|
||||
|
||||
it("is idempotent: applying the builder's output model to any plan yields healthy", () => {
|
||||
const fragmented = [u(2_400_750n), u(4_100_000n), u(1_679_301n), u(1_679_301n)];
|
||||
const plan = planConsolidate(fragmented);
|
||||
expect(plan.alreadyHealthy).toBe(false);
|
||||
|
||||
// Model of what buildConsolidateWalletTx lands for this plan.
|
||||
const afterConsolidation = [
|
||||
u(plan.collateralOutput),
|
||||
u(plan.workingOutput),
|
||||
u(DEFAULT_FEE_RESERVE - MAINNET_CONSOLIDATE_FEE),
|
||||
];
|
||||
const replan = planConsolidate(afterConsolidation);
|
||||
|
||||
expect(replan.reason).toBe("already-healthy");
|
||||
expect(replan.alreadyHealthy).toBe(true);
|
||||
});
|
||||
|
||||
it("still consolidates when fragmentation exceeds the max UTxO bound", () => {
|
||||
// Candidate present, but 7 UTxOs > DEFAULT_MAX_UTXOS (6).
|
||||
const utxos = [
|
||||
u(5_000_000n), u(1_100_000n), u(1_100_000n), u(1_100_000n),
|
||||
u(1_100_000n), u(1_100_000n), u(1_100_000n),
|
||||
];
|
||||
const plan = planConsolidate(utxos);
|
||||
expect(plan.reason).toBe("consolidate");
|
||||
});
|
||||
|
||||
it("still consolidates when no pure UTxO reaches the collateral threshold", () => {
|
||||
// The 2026-08-07 batch-stall shape: fragments only, max 4,498,372.
|
||||
const utxos = [u(4_498_372n), u(3_098_372n), u(1_498_372n)];
|
||||
const plan = planConsolidate(utxos);
|
||||
expect(plan.reason).toBe("consolidate");
|
||||
expect(plan.alreadyHealthy).toBe(false);
|
||||
});
|
||||
|
||||
it("a UTxO 1,628 lovelace short of collateral is not a candidate", () => {
|
||||
// Seen live: 4,998,372 (= 5.0 minus a 501,628 fee twice-shuffled).
|
||||
const utxos = [u(4_998_372n), u(9_000_000n - 4_998_372n)];
|
||||
const plan = planConsolidate(utxos);
|
||||
expect(plan.reason).toBe("consolidate");
|
||||
});
|
||||
|
||||
it("a UTxO carrying native assets is not a collateral candidate", () => {
|
||||
const withToken: UtxoLike = {
|
||||
assets: { lovelace: 6_000_000n, "deadbeef.72737456": 1n },
|
||||
};
|
||||
const utxos = [withToken, u(4_000_000n)];
|
||||
const plan = planConsolidate(utxos);
|
||||
expect(plan.reason).toBe("consolidate");
|
||||
});
|
||||
|
||||
it("healthy shape below the reshape threshold is healthy, not an error", () => {
|
||||
// {5.0, 2.5} = 7.5 total: under the 8.0 reshape minimum but perfectly
|
||||
// able to run script txs. The old order threw "balance too low" here
|
||||
// on every tick (1,744 log lines in the soak).
|
||||
const utxos = [u(5_000_000n), u(2_500_000n)];
|
||||
const plan = planConsolidate(utxos);
|
||||
expect(plan.reason).toBe("already-healthy");
|
||||
});
|
||||
|
||||
it("candidate with only dust outside it needs a reshape it cannot afford", () => {
|
||||
// {5.0, 0.4}: no working money outside the candidate, and 5.4 total is
|
||||
// below the reshape minimum — loud error, funds needed.
|
||||
const utxos = [u(5_000_000n), u(400_000n)];
|
||||
expect(() => planConsolidate(utxos)).toThrow(/balance too low/i);
|
||||
});
|
||||
});
|
||||
|
||||
200
tests/unit/tx-post-ocr-escrow.test.ts
Normal file
200
tests/unit/tx-post-ocr-escrow.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* tx-post-ocr-escrow.test.ts — coverage for the OCR escrow builder added
|
||||
* for model-scoped `ocr.page.extract.<model-slug>.v1` capabilities.
|
||||
*
|
||||
* Focus: the NEW validation + hash logic in postOcrEscrow.ts. The shared
|
||||
* escrow-construction pieces (advert resolution, datum assembly, mock tx
|
||||
* encoding) mirror postTtsEscrow.ts and are covered by tx-post-escrow.test.ts
|
||||
* for the chat shape; here we pin:
|
||||
* - ocrPromptHash canonicalisation (golden hash — buyer/supplier contract)
|
||||
* - envelope validation failures throw BEFORE any chain access
|
||||
* - the escrow datum carries the OCR prompt_hash on the happy path
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createHash } from "crypto";
|
||||
|
||||
import {
|
||||
ocrPromptHash,
|
||||
validateOcrRequest,
|
||||
buildPostOcrEscrowTx,
|
||||
MAX_OCR_IMAGE_B64_CHARS,
|
||||
TxConstructionError,
|
||||
type OcrRequest,
|
||||
} from "../../packages/shared/src/tx/index.js";
|
||||
import { canonicalize } from "../../packages/shared/src/cbor/canonical.js";
|
||||
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
|
||||
import { encodeAdvertDatum } from "../../packages/shared/src/cbor/AdvertDatum.js";
|
||||
import { decodeEscrowDatum } from "../../packages/shared/src/cbor/EscrowDatum.js";
|
||||
import { escrowLockFloor } from "../../packages/shared/src/tx/internal/minAdaFloor.js";
|
||||
import type { AdvertDatum } from "../../packages/shared/src/cbor/types.js";
|
||||
import type { Utxo, OutputReference } from "../../packages/shared/src/chain/ChainProvider.js";
|
||||
import { buildBuyerWalletKey } from "../fixtures/buyer-side/wallet-keys.js";
|
||||
import { buildSupplierWalletKey } from "../fixtures/supplier-side/wallet-keys.js";
|
||||
|
||||
const IMG = Buffer.from("not really a png but bytes are bytes").toString("base64");
|
||||
|
||||
function validRequest(): OcrRequest {
|
||||
return { image_b64: IMG, mime: "image/png", output_format: "markdown" };
|
||||
}
|
||||
|
||||
describe("ocrPromptHash — canonicalisation contract", () => {
|
||||
it("equals sha256(canonicalize({image_b64, mime, output_format}))", () => {
|
||||
const req = validRequest();
|
||||
const expected = createHash("sha256")
|
||||
.update(canonicalize({
|
||||
image_b64: req.image_b64,
|
||||
mime: req.mime,
|
||||
output_format: req.output_format,
|
||||
}), "utf8")
|
||||
.digest("hex");
|
||||
expect(ocrPromptHash(req)).toBe(expected);
|
||||
});
|
||||
|
||||
it("is key-order independent (JCS canonicalisation)", () => {
|
||||
const a = ocrPromptHash({ image_b64: IMG, mime: "image/png", output_format: "json" });
|
||||
const b = ocrPromptHash({ output_format: "json", mime: "image/png", image_b64: IMG } as OcrRequest);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("changes when any field changes", () => {
|
||||
const base = ocrPromptHash(validRequest());
|
||||
expect(ocrPromptHash({ ...validRequest(), mime: "image/jpeg" })).not.toBe(base);
|
||||
expect(ocrPromptHash({ ...validRequest(), output_format: "html" })).not.toBe(base);
|
||||
expect(ocrPromptHash({ ...validRequest(), image_b64: IMG + "AA" })).not.toBe(base);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateOcrRequest — envelope gates", () => {
|
||||
it("rejects empty image", () => {
|
||||
expect(() => validateOcrRequest({ ...validRequest(), image_b64: "" }))
|
||||
.toThrowError(TxConstructionError);
|
||||
});
|
||||
|
||||
it("rejects oversized image", () => {
|
||||
const big = "A".repeat(MAX_OCR_IMAGE_B64_CHARS + 4);
|
||||
expect(() => validateOcrRequest({ ...validRequest(), image_b64: big }))
|
||||
.toThrow(/exceeds/);
|
||||
});
|
||||
|
||||
it("rejects data-URL prefixes and whitespace (plain base64 only)", () => {
|
||||
for (const bad of [`data:image/png;base64,${IMG}`, `${IMG} `, "with space AA"]) {
|
||||
expect(() => validateOcrRequest({ ...validRequest(), image_b64: bad }))
|
||||
.toThrow(/base64/);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown mime and output_format", () => {
|
||||
expect(() => validateOcrRequest({ ...validRequest(), mime: "image/tiff" }))
|
||||
.toThrow(/mime/);
|
||||
expect(() => validateOcrRequest({ ...validRequest(), output_format: "yaml" }))
|
||||
.toThrow(/output_format/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPostOcrEscrowTx — validation precedes chain access", () => {
|
||||
it("throws the envelope error against an unseeded chain (no advert query first)", async () => {
|
||||
const chain = new MockChainProvider();
|
||||
const buyerKey = {
|
||||
pubKeyHash: "ab".repeat(28),
|
||||
pubKeyHex: "cd".repeat(32),
|
||||
privateKeyHex: "ef".repeat(32),
|
||||
address: "addr_test1fake",
|
||||
};
|
||||
await expect(buildPostOcrEscrowTx({
|
||||
chain,
|
||||
buyerKey,
|
||||
advertRef: { txHash: "b".repeat(64), index: 0 },
|
||||
request: { ...validRequest(), mime: "image/gif" },
|
||||
payment_lovelace: 200000n,
|
||||
})).rejects.toThrow(/mime/);
|
||||
});
|
||||
|
||||
it("unseeded advert ref → 'advert ref not on chain' after a valid envelope", async () => {
|
||||
const chain = new MockChainProvider();
|
||||
const buyerKey = {
|
||||
pubKeyHash: "ab".repeat(28),
|
||||
pubKeyHex: "cd".repeat(32),
|
||||
privateKeyHex: "ef".repeat(32),
|
||||
address: "addr_test1fake",
|
||||
};
|
||||
await expect(buildPostOcrEscrowTx({
|
||||
chain,
|
||||
buyerKey,
|
||||
advertRef: { txHash: "b".repeat(64), index: 0 },
|
||||
request: validRequest(),
|
||||
payment_lovelace: 200000n,
|
||||
})).rejects.toMatchObject({ reason: "advert ref not on chain" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPostOcrEscrowTx — happy path locks the min-ada floor (C1 regression)", () => {
|
||||
// Guards packages/shared/src/tx/escrow/postOcrEscrow.ts:205
|
||||
// (`escrowLockFloor(escrowDatum, economicTotal)`). Without that call the
|
||||
// escrow locks only the raw economic total, the Submit continuing output
|
||||
// gets bumped to min-ada, value_equal fails, and the validator errors with
|
||||
// an empty trace — the 2026-08-07 mainnet incident. Neither existing
|
||||
// `buildPostOcrEscrowTx` test above reaches line 205 (both throw first),
|
||||
// so this is the only test that would catch a regression back to a bare
|
||||
// `economicTotal` assignment.
|
||||
const ADVERT_SCRIPT_ADDRESS = "addr_test1wrqq9qqjzf3uh4w9hm0kqzrpvt60r4ryjp5rjf5epd3nptq7yscm6";
|
||||
const ADVERT_REF: OutputReference = { txHash: "c".repeat(64), index: 0 };
|
||||
const PRICE = 200_000n;
|
||||
const BUYER_BOND = 1_000_000n;
|
||||
const SUPPLIER_BOND = 1_000_000n;
|
||||
const ECONOMIC_TOTAL = PRICE + BUYER_BOND + SUPPLIER_BOND; // 2_200_000n
|
||||
|
||||
function makeOcrAdvert(): AdvertDatum {
|
||||
const supplier = buildSupplierWalletKey();
|
||||
return {
|
||||
supplier_pkh: supplier.pubKeyHash,
|
||||
capability_id: "ocr.page.extract.chandra-ocr-2.v1",
|
||||
model: "chandra-ocr-2",
|
||||
max_output_tokens: 4096,
|
||||
max_processing_ms: 60_000,
|
||||
price_lovelace: PRICE,
|
||||
supplier_bond_lovelace: SUPPLIER_BOND,
|
||||
buyer_bond_lovelace: BUYER_BOND,
|
||||
endpoint_url: "https://supplier.example.com/v1",
|
||||
detail_uri: "ipfs://Qm000",
|
||||
detail_hash: "a".repeat(64),
|
||||
advertised_at: 1_745_500_000_000,
|
||||
status: "Active",
|
||||
};
|
||||
}
|
||||
|
||||
function seedOcrAdvert(chain: MockChainProvider): void {
|
||||
const utxo: Utxo = {
|
||||
ref: ADVERT_REF,
|
||||
address: ADVERT_SCRIPT_ADDRESS,
|
||||
lovelace: 2_000_000n,
|
||||
assets: {},
|
||||
datumHex: encodeAdvertDatum(makeOcrAdvert()),
|
||||
scriptRef: null,
|
||||
};
|
||||
chain.seed(utxo);
|
||||
}
|
||||
|
||||
it("locks escrowLockFloor(datum, economicTotal), strictly above the raw economic total", async () => {
|
||||
const chain = new MockChainProvider();
|
||||
chain.advanceSlot(1_745_500_000);
|
||||
seedOcrAdvert(chain);
|
||||
|
||||
const buyer = buildBuyerWalletKey();
|
||||
const result = await buildPostOcrEscrowTx({
|
||||
chain,
|
||||
buyerKey: buyer,
|
||||
advertRef: ADVERT_REF,
|
||||
request: validRequest(),
|
||||
payment_lovelace: PRICE,
|
||||
});
|
||||
|
||||
const escrowUtxo = await chain.queryUtxo(result.escrowOutputRef);
|
||||
expect(escrowUtxo).not.toBeNull();
|
||||
const datum = decodeEscrowDatum(escrowUtxo!.datumHex!);
|
||||
|
||||
const expectedFloor = escrowLockFloor(datum, ECONOMIC_TOTAL);
|
||||
expect(escrowUtxo!.lovelace).toBe(expectedFloor);
|
||||
expect(escrowUtxo!.lovelace).toBeGreaterThan(ECONOMIC_TOTAL);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,8 @@ RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
# the whole workspace, so every package's manifest must be present even though
|
||||
# --filter only installs what wallet-monitor needs.
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
|
||||
# pnpm-lock.yaml's patchedDependencies hash the patch files at install time.
|
||||
COPY patches ./patches
|
||||
COPY packages/shared/package.json ./packages/shared/package.json
|
||||
COPY wallet-monitor/package.json ./wallet-monitor/package.json
|
||||
COPY buyer/package.json ./buyer/package.json
|
||||
|
||||
Reference in New Issue
Block a user