diff --git a/buyer/src/sdk/Marketplace.ts b/buyer/src/sdk/Marketplace.ts index f61805e..727c429 100644 --- a/buyer/src/sdk/Marketplace.ts +++ b/buyer/src/sdk/Marketplace.ts @@ -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..v1 ── + + async submitOcr(opts: SubmitOcrOptions): Promise { + 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((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 ──────────── /** diff --git a/buyer/src/sdk/types.ts b/buyer/src/sdk/types.ts index 1ddad06..a8c00bd 100644 --- a/buyer/src/sdk/types.ts +++ b/buyer/src/sdk/types.ts @@ -187,6 +187,35 @@ export interface SubmitTtsResult { escrowRef: OutputReference; } +/** + * SubmitOcrOptions — arguments to Marketplace.submitOcr() for model-scoped + * `ocr.page.extract..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 diff --git a/gateway/src/config.ts b/gateway/src/config.ts index f67017a..f11b5f0 100644 --- a/gateway/src/config.ts +++ b/gateway/src/config.ts @@ -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, name: string): string { @@ -138,6 +141,7 @@ export function loadConfig(env: Record): 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(/\/+$/, "")) diff --git a/gateway/src/index.ts b/gateway/src/index.ts index 50367f6..8c634a6 100644 --- a/gateway/src/index.ts +++ b/gateway/src/index.ts @@ -57,7 +57,10 @@ export function runMain(env: Record): 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 { diff --git a/gateway/src/ocr/extract.ts b/gateway/src/ocr/extract.ts new file mode 100644 index 0000000..0874daa --- /dev/null +++ b/gateway/src/ocr/extract.ts @@ -0,0 +1,402 @@ +/** + * gateway/src/ocr/extract.ts — POST /v1/ocr/extract. + * + * One escrow per page against a model-scoped `ocr.page.extract..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; + + 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 { + 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, + parsed: ParsedOcrRequest, + attribution: { venue: string; runUserHash: string }, + res: Response, +): Promise { + 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> | 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; + } +} diff --git a/gateway/src/onchain/preflight.ts b/gateway/src/onchain/preflight.ts index 8ede3a7..3fe009d 100644 --- a/gateway/src/onchain/preflight.ts +++ b/gateway/src/onchain/preflight.ts @@ -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 { 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, diff --git a/gateway/src/routing/selectSupplier.ts b/gateway/src/routing/selectSupplier.ts index 9687a75..39e2ab5 100644 --- a/gateway/src/routing/selectSupplier.ts +++ b/gateway/src/routing/selectSupplier.ts @@ -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 + 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)); diff --git a/gateway/test/http.test.ts b/gateway/test/http.test.ts index 7bb3e8f..df901d7 100644 --- a/gateway/test/http.test.ts +++ b/gateway/test/http.test.ts @@ -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"]); diff --git a/gateway/test/ocr.test.ts b/gateway/test/ocr.test.ts new file mode 100644 index 0000000..f696259 --- /dev/null +++ b/gateway/test/ocr.test.ts @@ -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(); + 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): Parameters[0] { + return { header: (name: string) => headers[name] } as unknown as Parameters[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, + statusByEndpoint: Record 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); + }); +}); diff --git a/gateway/test/sweeper.test.ts b/gateway/test/sweeper.test.ts index f47dcf9..d392bef 100644 --- a/gateway/test/sweeper.test.ts +++ b/gateway/test/sweeper.test.ts @@ -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"]; diff --git a/gateway/test/unit.test.ts b/gateway/test/unit.test.ts index c435613..2f5bd1a 100644 --- a/gateway/test/unit.test.ts +++ b/gateway/test/unit.test.ts @@ -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); diff --git a/packages/shared/src/tx/escrow/postOcrEscrow.ts b/packages/shared/src/tx/escrow/postOcrEscrow.ts new file mode 100644 index 0000000..584e256 --- /dev/null +++ b/packages/shared/src/tx/escrow/postOcrEscrow.ts @@ -0,0 +1,257 @@ +/** + * tx/escrow/postOcrEscrow.ts — PostEscrow tx builder for model-scoped OCR + * capabilities (`ocr.page.extract..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 { + 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 }, + }; +} diff --git a/packages/shared/src/tx/index.ts b/packages/shared/src/tx/index.ts index 7e0b3ee..dd5b5c7 100644 --- a/packages/shared/src/tx/index.ts +++ b/packages/shared/src/tx/index.ts @@ -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"; diff --git a/supplier/.env.chandra.example b/supplier/.env.chandra.example new file mode 100644 index 0000000..09a7285 --- /dev/null +++ b/supplier/.env.chandra.example @@ -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 `). +# 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= # # 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@". +# 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:// \ +# --detail-uri https:///chandra-detail.json \ +# --detail-hash +# +# 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://: endpoint +# works for the testnet advert (retire it after the drills). diff --git a/supplier/chandra-detail.example.json b/supplier/chandra-detail.example.json new file mode 100644 index 0000000..60dc173 --- /dev/null +++ b/supplier/chandra-detail.example.json @@ -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": "", + "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)" +} diff --git a/supplier/src/config.ts b/supplier/src/config.ts index 2c6c5a4..df17b26 100644 --- a/supplier/src/config.ts +++ b/supplier/src/config.ts @@ -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..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): 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): 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): 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): SupplierCon chatIdleTimeoutMs, maxChatSessions, chatSettleMode, + ocrUpstream, + datalabApiKey, + datalabBaseUrl, + datalabMode, + datalabTimeoutMs, + ocrPromptOverride, + hfModelRevision, + revisionProbeUrl, + revisionProbeMode, liveChain, walletHealthIntervalMs, }; diff --git a/supplier/src/datalabOcr.ts b/supplier/src/datalabOcr.ts new file mode 100644 index 0000000..e84d617 --- /dev/null +++ b/supplier/src/datalabOcr.ts @@ -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 = { + "image/png": "png", + "image/jpeg": "jpg", + "image/webp": "webp", +}; + +function sleep(ms: number, signal: AbortSignal): Promise { + 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 { + 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; + try { + submitBody = await submitRes.json() as Record; + } 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; + try { + poll = await pollRes.json() as Record; + } 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 { + 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); + } +} diff --git a/supplier/src/jobRunner.ts b/supplier/src/jobRunner.ts index 746aec1..267fb2d 100644 --- a/supplier/src/jobRunner.ts +++ b/supplier/src/jobRunner.ts @@ -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 { } } } + +// ─── runOcrJob ──────────────────────────────────────────────────────────── +// +// Mirror of runTtsJob for model-scoped `ocr.page.extract..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 { + 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, + receipt_signature: signed.signature, + }; + deps.jobs.complete(jobId, payload); + } finally { + try { + deps.state.release(escrowRef); + } catch { + /* never let lock-release escape */ + } + } +} diff --git a/supplier/src/jobs.ts b/supplier/src/jobs.ts index ead50e0..3dbe5e7 100644 --- a/supplier/src/jobs.ts +++ b/supplier/src/jobs.ts @@ -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; + 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; diff --git a/supplier/src/ocrVision.ts b/supplier/src/ocrVision.ts new file mode 100644 index 0000000..118eb8c --- /dev/null +++ b/supplier/src/ocrVision.ts @@ -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:;base64,"}}, + * {type:"text", text:}] + * 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 { + 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 = { + 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 = { "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; + 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).message; + if (!messageRaw || typeof messageRaw !== "object") { + throw new OcrVisionError("ocr_malformed", "OCR response missing 'choices[0].message'"); + } + const content = (messageRaw as Record).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; + 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 { + const { probeUrl, timeoutMs, apiKey } = params; + const headers: Record = {}; + 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; + 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); + } +} diff --git a/supplier/src/server.ts b/supplier/src/server.ts index e17379a..c19d09b 100644 --- a/supplier/src/server.ts +++ b/supplier/src/server.ts @@ -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..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>#"'); + } + const escrowRefStr = `${escrowRef.txHash}#${escrowRef.index}`; + + // ── 2. Body ───────────────────────────────────────────────────── + const body = (req.body ?? {}) as Record; + 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); diff --git a/tests/fixtures/supplier-side/sample-config.ts b/tests/fixtures/supplier-side/sample-config.ts index 00d3507..9e8e980 100644 --- a/tests/fixtures/supplier-side/sample-config.ts +++ b/tests/fixtures/supplier-side/sample-config.ts @@ -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 { return { diff --git a/tests/unit/supplier-datalab-ocr.test.ts b/tests/unit/supplier-datalab-ocr.test.ts new file mode 100644 index 0000000..06ce4c1 --- /dev/null +++ b/tests/unit/supplier-datalab-ocr.test.ts @@ -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[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)["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)["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: "

x

" }), { 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)["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): Record { + const env = buildSampleEnv(); + delete (env as Record).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/); + }); +}); diff --git a/tests/unit/supplier-ocr-vision.test.ts b/tests/unit/supplier-ocr-vision.test.ts new file mode 100644 index 0000000..74cad77 --- /dev/null +++ b/tests/unit/supplier-ocr-vision.test.ts @@ -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 = {}; + let auth = ""; + vi.stubGlobal("fetch", async (_url: string, init: RequestInit) => { + sent = JSON.parse(String(init.body)); + auth = (init.headers as Record).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); + }); +}); diff --git a/tests/unit/supplier-routes-ocr.test.ts b/tests/unit/supplier-routes-ocr.test.ts new file mode 100644 index 0000000..0f40b50 --- /dev/null +++ b/tests/unit/supplier-routes-ocr.test.ts @@ -0,0 +1,176 @@ +/** + * supplier-routes-ocr.test.ts — coverage for the model-scoped OCR supplier + * route (`ocr.page.extract..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>#", 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"); + }); +}); diff --git a/tests/unit/tx-post-ocr-escrow.test.ts b/tests/unit/tx-post-ocr-escrow.test.ts new file mode 100644 index 0000000..ceb9001 --- /dev/null +++ b/tests/unit/tx-post-ocr-escrow.test.ts @@ -0,0 +1,200 @@ +/** + * tx-post-ocr-escrow.test.ts — coverage for the OCR escrow builder added + * for model-scoped `ocr.page.extract..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); + }); +});