Merge pull request #10 from Apex-Fusion/feat/ocr-capability

feat(ocr): OCR capability + gateway /v1/ocr/extract route
This commit is contained in:
dcejtlin-apex
2026-08-11 09:40:25 +02:00
committed by GitHub
27 changed files with 3683 additions and 26 deletions

View File

@@ -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 ────────────
/**

View File

@@ -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

View File

@@ -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(/\/+$/, ""))

View File

@@ -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
View 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;
}
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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));

View File

@@ -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
View 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);
});
});

View File

@@ -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"];

View File

@@ -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);

View 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 },
};
}

View File

@@ -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";

View 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).

View 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)"
}

View File

@@ -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
View 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);
}
}

View File

@@ -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 */
}
}
}

View File

@@ -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
View 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);
}
}

View File

@@ -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);

View File

@@ -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 {

View 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/);
});
});

View 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);
});
});

View 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");
});
});

View 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);
});
});