feat(ocr): model-scoped OCR capability + gateway /v1/ocr/extract route
Adds the ocr.page.extract.chandra-ocr-2.v1 capability end to end: the postOcrEscrow builder (locking the min-ada floor for the Submitted-state datum, required since the min-ada fix), the gateway OCR route that selects an OCR supplier by capability, posts a per-page escrow, calls the supplier, verifies and settles, and returns content plus the signed receipt labelled first-party-brokered. Supplier OCR handler with a hosted document-API upstream and buyer-SDK OCR support included. Squashed from the 2026-08-06 working subset; the deploy pack is intentionally excluded (it lives in a private operator repo). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,9 @@ export interface GatewayConfig {
|
||||
walletHealthIntervalMs: number;
|
||||
sdkRegistryMax: number;
|
||||
corsOrigins: string[];
|
||||
/** Capability id the /v1/ocr/extract route escrows against. Model-scoped
|
||||
* per the marketplace convention (one model, one id). Env: OCR_CAPABILITY_ID. */
|
||||
ocrCapabilityId: string;
|
||||
}
|
||||
|
||||
function requireField(env: Record<string, string | undefined>, name: string): string {
|
||||
@@ -138,6 +141,7 @@ export function loadConfig(env: Record<string, string | undefined>): GatewayConf
|
||||
chatSettleMode: chatSettleMode(env),
|
||||
walletHealthIntervalMs: posInt(env, "WALLET_HEALTH_INTERVAL_MS", 10 * 60 * 1000),
|
||||
sdkRegistryMax: posInt(env, "SDK_REGISTRY_MAX", 500),
|
||||
ocrCapabilityId: env.OCR_CAPABILITY_ID ?? "ocr.page.extract.chandra-ocr-2.v1",
|
||||
corsOrigins: (env.GATEWAY_CORS_ORIGINS ?? "")
|
||||
.split(",")
|
||||
.map((o) => o.trim().replace(/\/+$/, ""))
|
||||
|
||||
308
gateway/src/ocr/extract.ts
Normal file
308
gateway/src/ocr/extract.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* gateway/src/ocr/extract.ts — POST /v1/ocr/extract.
|
||||
*
|
||||
* One escrow per page against a model-scoped `ocr.page.extract.<model>.v1`
|
||||
* supplier (config.ocrCapabilityId). Lifecycle mirrors the one-shot chat
|
||||
* path, all inside the per-key mutex: parse → select supplier → preflight →
|
||||
* 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 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 } 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}$/;
|
||||
|
||||
interface ParsedOcrRequest {
|
||||
image_b64: string;
|
||||
mime: string;
|
||||
output_format: string;
|
||||
/** "" = any model under the capability (single-model ids). */
|
||||
model: string;
|
||||
}
|
||||
|
||||
function parseOcrRequest(body: unknown): ParsedOcrRequest {
|
||||
if (typeof body !== "object" || body === null) {
|
||||
throw badRequest("invalid_body", "request body must be a JSON object");
|
||||
}
|
||||
const b = body as Record<string, unknown>;
|
||||
|
||||
const image = b.image_b64;
|
||||
if (typeof image !== "string" || image.length === 0) {
|
||||
throw badRequest("invalid_image", "`image_b64` is required and must be a non-empty base64 string");
|
||||
}
|
||||
if (image.length > MAX_OCR_IMAGE_B64_CHARS) {
|
||||
throw badRequest("image_too_large", `\`image_b64\` exceeds ${MAX_OCR_IMAGE_B64_CHARS} characters`);
|
||||
}
|
||||
if (!B64_RE.test(image)) {
|
||||
throw badRequest("invalid_image", "`image_b64` must be plain base64 (no data: prefix, no whitespace)");
|
||||
}
|
||||
|
||||
const mime = b.mime;
|
||||
if (typeof mime !== "string" || !ALLOWED_OCR_MIMES.has(mime)) {
|
||||
throw badRequest("invalid_mime", `\`mime\` must be one of: ${[...ALLOWED_OCR_MIMES].join(", ")}`);
|
||||
}
|
||||
|
||||
const rawFormat = b.output_format ?? "markdown";
|
||||
if (typeof rawFormat !== "string" || !ALLOWED_OCR_OUTPUT_FORMATS.has(rawFormat)) {
|
||||
throw badRequest(
|
||||
"invalid_output_format",
|
||||
`\`output_format\` must be one of: ${[...ALLOWED_OCR_OUTPUT_FORMATS].join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
let model = "";
|
||||
if (b.model !== undefined && b.model !== null) {
|
||||
if (typeof b.model !== "string" || b.model === "") {
|
||||
throw badRequest("invalid_model", "`model` must be a non-empty string when provided");
|
||||
}
|
||||
model = b.model;
|
||||
}
|
||||
|
||||
return { image_b64: image, mime, output_format: rawFormat, model };
|
||||
}
|
||||
|
||||
/** Sanitized venue-attribution headers. Invalid values drop to "". */
|
||||
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() : "",
|
||||
};
|
||||
}
|
||||
|
||||
function recordOcrUsage(
|
||||
store: GatewayStore,
|
||||
p: {
|
||||
keyId: string;
|
||||
capabilityId: string;
|
||||
model: string;
|
||||
supplierPkh: string | null;
|
||||
escrowRef: string | null;
|
||||
costLovelace: bigint | null;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
status: "completed" | "failed";
|
||||
failureReason?: string;
|
||||
},
|
||||
): void {
|
||||
store.insertUsage({
|
||||
id: randomUUID(),
|
||||
key_id: p.keyId,
|
||||
created_at: Date.now(),
|
||||
kind: "ocr",
|
||||
model: p.model,
|
||||
capability_id: p.capabilityId,
|
||||
supplier_pkh: p.supplierPkh,
|
||||
escrow_ref: p.escrowRef,
|
||||
cost_lovelace: p.costLovelace !== null ? p.costLovelace.toString() : null,
|
||||
prompt_tokens: p.promptTokens,
|
||||
completion_tokens: p.completionTokens,
|
||||
status: p.status,
|
||||
failure_reason: p.failureReason ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export function makeOcrExtractHandler(deps: GatewayDeps) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const keyRow = requireKey(req);
|
||||
if (keyRow.demo) {
|
||||
throw forbidden("demo_key_unsupported", "demo keys cannot use /v1/ocr/extract; create a funded API key");
|
||||
}
|
||||
const ctx = deps.registry.getContext(keyRow);
|
||||
const parsed = parseOcrRequest(req.body);
|
||||
const attribution = venueAttribution(req);
|
||||
await ctx.mutex.run(() => runOcrOneShot(deps, keyRow.id, ctx, parsed, attribution, res));
|
||||
});
|
||||
}
|
||||
|
||||
async function runOcrOneShot(
|
||||
deps: GatewayDeps,
|
||||
keyId: string,
|
||||
ctx: ReturnType<GatewayDeps["registry"]["getContext"]>,
|
||||
parsed: ParsedOcrRequest,
|
||||
attribution: { venue: string; runUserHash: string },
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const { config, store, chain, fetchFn } = deps;
|
||||
const capabilityId = config.ocrCapabilityId;
|
||||
|
||||
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).
|
||||
let result: Awaited<ReturnType<typeof ctx.sdk.submitOcr>> | undefined;
|
||||
let used: (typeof candidates)[number] | undefined;
|
||||
let lastErr: unknown;
|
||||
for (const cand of candidates) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (!result || !used) {
|
||||
throw toGatewayError(lastErr ?? new Error("no supplier could be engaged"));
|
||||
}
|
||||
|
||||
// Settle: resolve the live Submitted ref, Accept, await confirmation.
|
||||
const originalRefStr = `${result.escrowRef.txHash}#${result.escrowRef.index}`;
|
||||
const submittedRef = await resolveSubmittedRef({
|
||||
indexerUrl: config.indexerUrl,
|
||||
buyerPkh: ctx.walletKey.pubKeyHash,
|
||||
originalRefStr,
|
||||
fetchFn,
|
||||
});
|
||||
await acceptAndConfirm(chain, ctx.walletKey, submittedRef);
|
||||
|
||||
recordOcrUsage(store, {
|
||||
keyId,
|
||||
capabilityId,
|
||||
model: used.model,
|
||||
supplierPkh: used.supplierPkh,
|
||||
escrowRef: originalRefStr,
|
||||
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: originalRefStr,
|
||||
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: originalRefStr,
|
||||
receipt_labels: RECEIPT_LABELS_V0,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
recordOcrUsage(store, {
|
||||
keyId,
|
||||
capabilityId,
|
||||
model: parsed.model,
|
||||
supplierPkh: null,
|
||||
escrowRef: null,
|
||||
costLovelace: null,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
status: "failed",
|
||||
failureReason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,9 @@ export function parseRef(ref: string): OutputReference | null {
|
||||
|
||||
export interface SelectSupplierOpts {
|
||||
indexerUrl: string;
|
||||
/** Exact model match; "" matches ANY model under the capability (used by
|
||||
* single-model capabilities like the ocr.* ids, where the capability
|
||||
* already pins the model). */
|
||||
model: string;
|
||||
capabilityId: string;
|
||||
fetchFn?: typeof globalThis.fetch;
|
||||
@@ -82,7 +85,7 @@ export async function selectCandidates(opts: SelectSupplierOpts): Promise<Suppli
|
||||
const candidates: SupplierCandidate[] = [];
|
||||
for (const raw of body as SupplierView[]) {
|
||||
if (raw.capability_id !== opts.capabilityId) continue;
|
||||
if (raw.model !== opts.model) continue;
|
||||
if (opts.model !== "" && raw.model !== opts.model) continue;
|
||||
if (raw.advert_status !== "Active") continue;
|
||||
if (raw.status !== "free" && raw.status !== "unknown" && !opts.ignoreStatusFor?.has(raw.supplier_pkh)) {
|
||||
continue;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ipRateLimit, keyRateLimit, demoIpRateLimit } from "./middleware/rateLim
|
||||
import { sendError } from "./middleware/http.js";
|
||||
import { notFound } from "./openai/errors.js";
|
||||
import { makeChatCompletionsHandler } from "./openai/chatCompletions.js";
|
||||
import { makeOcrExtractHandler } from "./ocr/extract.js";
|
||||
import { makeModelsHandler } from "./openai/models.js";
|
||||
import {
|
||||
makeOpenSessionHandler,
|
||||
@@ -56,7 +57,11 @@ export function createApp(deps: GatewayDeps): Express {
|
||||
app.set("trust proxy", true);
|
||||
// CORS before body-parsing so preflight OPTIONS short-circuits cheaply.
|
||||
app.use(corsMiddleware(deps.config.corsOrigins));
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
// /v1/ocr carries a base64 page image and mounts its own larger parser on
|
||||
// the route; every other path keeps the 1mb ceiling.
|
||||
const jsonBody = express.json({ limit: "1mb" });
|
||||
app.use((req, res, next) =>
|
||||
req.path.startsWith("/v1/ocr") ? next() : jsonBody(req, res, next));
|
||||
|
||||
app.get("/healthz", (_req: Request, res: Response) => {
|
||||
res.json({ ok: true });
|
||||
@@ -80,6 +85,9 @@ 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.
|
||||
app.post("/v1/ocr/extract", express.json({ limit: "16mb" }), ...gated, makeOcrExtractHandler(deps));
|
||||
app.get("/openai/v1/models", auth, makeModelsHandler(deps));
|
||||
app.post("/openai/v1/chat/sessions", ...gated, makeOpenSessionHandler(deps));
|
||||
app.post("/openai/v1/chat/sessions/:id/messages", ...gated, makeSessionMessageHandler(deps));
|
||||
|
||||
@@ -46,6 +46,7 @@ function makeDeps(
|
||||
chatSettleMode: overrides?.chatSettleMode ?? "full",
|
||||
walletHealthIntervalMs: 600_000,
|
||||
sdkRegistryMax: 100,
|
||||
ocrCapabilityId: "ocr.page.extract.chandra-ocr-2.v1",
|
||||
corsOrigins: [],
|
||||
};
|
||||
const chain = overrides?.chain ?? ({ queryUtxosByAddress: async () => [] } as unknown as GatewayDeps["chain"]);
|
||||
|
||||
196
gateway/test/ocr.test.ts
Normal file
196
gateway/test/ocr.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* 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 } 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 type { GatewayConfig } from "../src/config.js";
|
||||
import type { GatewayDeps } from "../src/deps.js";
|
||||
|
||||
const MASTER = "ab".repeat(32);
|
||||
const IMG = Buffer.from("page bytes").toString("base64");
|
||||
|
||||
function makeDeps(fetchFn?: typeof globalThis.fetch): 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 = { 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);
|
||||
});
|
||||
});
|
||||
@@ -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"];
|
||||
|
||||
Reference in New Issue
Block a user